//! lint:off scope-balance: ImGui provider - begin*/end* here are the API definitions/forwarders, not paired usage.
// src/ui.zig - immediate-mode UI port, ImGui-style.
// Adapted from Dear ImGui by Omar Cornut, MIT license. Render path
// adapted from rlImGui by Jeffery Myers, zlib license. See
// THIRD_PARTY_LICENSES.md for full attribution and copies.
// scope (THIS file at this state in the project):
//   - Data shape: UiContext (lives in App), Ui handle (per Frame),
//     Window (persistent per-title), Style, plus the geometry
//     primitives borrowed from src/types.zig.
//   - ID stack (FNV-1a hashed) - disambiguates widgets sharing labels.
//   - Layout cursor - widgets advance it; sameLine keeps it on row.
//   - Hit-test + active item state machine - the foundation under
//     every interactive widget.
//   - Internal draw primitives - drawRectFilled / drawRect / drawText
//     routed through rlgl's immediate-mode (rlBegin/rlVertex2f/etc.).
//   - One widget: button. Proves the architecture end-to-end.
//   - Window opens/closes via the optional-handle pattern:
//        if (ui.window("Stats",.{})) |w| {
//            defer w.close();
//            if (ui.button("Reset",.{})) state.counter = 0;
//        }
// What's NOT in (intentionally - see docs/ui-design.md):
//   - More widgets (brings the seven essentials)
//   - Window resize / drag-to-move / collapse
//   - Multi-window z-ordering (current impl just uses submission order)
//   - Style push/pop stacks (per-widget opts cover the needs)
//   - Popups, menus, tab bars, child regions (later phases)
//   - Tables, docking, multi-viewport (out of scope entirely)

const std = @import("std");
const ArrayList = std.ArrayList;
const allocPrint = std.fmt.allocPrint;
const bufPrint = std.fmt.bufPrint;
const eql = std.mem.eql;
const expect = std.testing.expect;
const expectApproxEqAbs = std.testing.expectApproxEqAbs;
const expectEqual = std.testing.expectEqual;
const expectEqualDeep = std.testing.expectEqualDeep;
const expectEqualSlices = std.testing.expectEqualSlices;
const expectEqualStrings = std.testing.expectEqualStrings;
const expectError = std.testing.expectError;
const meta = std.meta;
const zm = @import("zm");
const float64 = zm.float64;
const radFromDeg = zm.radFromDeg;
const FrameArena = @import("frame_arena.zig").FrameArena;
/// Ceiling for the UI per-frame arena: well above one honest frame of draw
/// payload, far below the wasm ~2GB wall. Trips FrameArena if the per-frame
/// reset is ever dropped (the leak that motivated this).
const ui_frame_arena_ceiling: usize = 256 * 1024 * 1024;
const hsvToRgb3 = zm.hsvToRgb3;
const rgbToHsv3 = zm.rgbToHsv3;
const acos = zm.acos;
const atan2 = zm.atan2;
const clamp = zm.clamp;
const float = zm.float;
const floori = zm.floori;
const isFinite = zm.isFinite;
const maxInt = zm.maxInt;
const pi = zm.pi;
const degFromRad = zm.degFromRad;
const sqrt1_2 = zm.sqrt1_2;
const tau = zm.tau;
const assert = zm.assert;
const builtin = @import("builtin");

// All cross-file deps go via direct `@import` of the sibling
// file. ui.zig is a regular file in the zimr module - within
// the module, file imports may cycle (wgpu_app.zig imports ui.zig
// for the UiHost bridge, ui.zig imports WgpuGl.zig
// for the `Frame` type). This is how the "high-level zimr can
// use ui, ui can use Frame" property is achieved without
// architectural surgery.
const types = @import("types.zig");
pub const Font = types.Font;
const RenderTexture = types.RenderTexture;
const text2d = @import("text2d.zig");
const shapes2d = @import("shapes2d.zig");
const easings = @import("easings.zig");
const draw2d = @import("draw2d.zig");
const Sprite = @import("Sprite.zig");

const build_options = @import("build_options");
/// Inert renderer handle for host-test builds (GL-retirement P4: the GL
/// backend is gone; tests pass `&stub` into beginFrameRaw and rasterize via
/// raster instead).  Methods are added as the compiler demands them — all
/// no-ops.
pub const TestGlStub = shapes2d.TestGl;

/// The renderer-state type the UI draws through. Selected at BUILD TIME:
/// WgpuGl for the WebGPU build (the only real backend since GL-retirement
/// P4), an inert stub for host-test builds. Both satisfy the renderer trait
/// the DrawList replay (shapes2d/text) calls
/// (begin/vertex2f/color4ub/setTexture/matrixMode/...).
/// build_options.ui_backend_wgpu is true in the wgpu module, false in the
/// host-test module — where rendering goes through raster (`renderToBytes`),
/// never through this handle.
pub const Gl = if (build_options.ui_backend_wgpu)
    @import("WgpuGl.zig")
else
    TestGlStub;

const raster = @import("raster.zig");
const runtime = @import("runtime.zig");
const codecs = @import("codecs.zig");
const assertf = zm.assertf;
const BoundedArray = @import("utils.zig").BoundedArray;
const warnOnce = @import("utils.zig").warnOnce;

const Allocator = std.mem.Allocator;

/// Null-terminated copy of `bytes`. Replaces `Allocator.dupeZ`, removed in Zig
/// 0.17 (allocSentinel + memcpy, per src/notes/zig17_migration.md). Caller owns
/// the result and frees with `allocator.free`.
fn dupeZ(allocator: Allocator, bytes: []const u8) Allocator.Error![:0]u8 {
    const out: [:0]u8 = try allocator.allocSentinel(u8, bytes.len, 0);
    @memcpy(out, bytes);
    return out;
}
/// Re-exported from `types` for `DrawListHandle` callers - passed
/// to `addLine`, `addCircle`, etc., and for slider/widget signatures.
const Vec2 = zm.Vec2;
/// Re-exported from `types` for custom widget signatures —
/// extension authors that paint inside their widget's rect take a
/// `Color` arg, so making it reachable as `Color` saves having
/// to add `const z = @import("zimr"); const Color = Color;` at
/// every call site.
const Color = zm.Color;
/// Re-exported from `types` for `DrawListHandle.addRectFilled` etc.
pub const Rectangle = types.Rectangle;

/// Intersection of two clip rects (CSS-px space). Returns a zero-size rect if
/// they are disjoint. Used by the draw-list replay's clip stack so a nested
/// clip can never exceed its parent.
fn intersectClip(a: Rectangle, b: Rectangle) Rectangle {
    const x0: f32 = @max(a.x, b.x);
    const y0: f32 = @max(a.y, b.y);
    const x1: f32 = @min(a.x + a.width, b.x + b.width);
    const y1: f32 = @min(a.y + a.height, b.y + b.height);
    return .{ .x = x0, .y = y0, .width = @max(0, x1 - x0), .height = @max(0, y1 - y0) };
}
/// Re-exported from `runtime.input` - passed to `Ui.setMouseCursor`
/// and returned by `Ui.getMouseCursor`. Surfacing it here means
/// callers can write `ui.setMouseCursor(.pointing_hand)` without
/// reaching into `z.input.*`.
pub const MouseCursor = runtime.input.MouseCursor;
/// Re-exported from `types` for the drag-helper signatures so
/// callers can write `ui.isMouseDragging(.left, -1)` directly.
pub const MouseButton = types.MouseButton;
/// Re-exported from `runtime.input` for `Ui.shortcut` callers.
/// Construct inline at call sites: `.{.key =.s,.ctrl = true }`.
pub const KeyChord = runtime.input.KeyChord;
/// Re-exported from `runtime.input` for `Ui.shortcut` callers.
/// Currently a single field - `repeat: bool = false`.
pub const ShortcutOpts = runtime.input.ShortcutOpts;

// ============================================================================
// BoundedStack - fixed-capacity stack on the stack frame.
// Zig 0.16 removed std.BoundedArray. We need something simple for the
// small fixed stacks (window_stack, id_stack) that don't justify heap
// allocation. Six lines of obvious code; not worth a stdlib dep.
// ============================================================================

fn BoundedStack(comptime T: type, comptime cap: usize) type {
    return struct {
        items: [cap]T = undefined,
        len: usize = 0,

        const Self = @This();

        pub fn append(self: *Self, v: T) error{Overflow}!void {
            if (self.len >= cap) return error.Overflow;
            self.items[self.len] = v;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }

        pub fn top(self: *const Self) ?T {
            if (self.len == 0) return null;
            return self.items[self.len - 1];
        }

        pub fn clear(self: *Self) void {
            self.len = 0;
        }
    };
}

test "BoundedStack: append/pop/top/clear semantics" {
    var s = BoundedStack(u32, 4){};
    try expectEqual(@as(?u32, null), s.top());

    try s.append(10);
    try s.append(20);
    try expectEqual(@as(usize, 2), s.len);
    try expectEqual(@as(?u32, 20), s.top());

    try expectEqual(@as(?u32, 20), s.pop());
    try expectEqual(@as(usize, 1), s.len);

    s.clear();
    try expectEqual(@as(usize, 0), s.len);
    try expectEqual(@as(?u32, null), s.pop());
}

test "BoundedStack: append on full returns Overflow" {
    var s = BoundedStack(u32, 2){};
    try s.append(1);
    try s.append(2);
    try expectError(error.Overflow, s.append(3));
}

// ============================================================================
// Public types
// ============================================================================

/// Stable widget identifier, computed by hashing the ID stack +
/// label. Same as ImGui's `ImGuiID` - a u32. Collisions are
/// vanishingly rare in practice; if they happen the user calls
/// `pushId(uniqueValue)` to disambiguate.
pub const Id = u32;

// ============================================================================
// ID hashing - FNV-1a with seeded chaining, identical to ImGui's
// algorithm so test fixtures are interoperable.
// ============================================================================

const fnv_offset: u32 = 2166136261;
const fnv_prime: u32 = 16777619;

pub fn hashStr(seed: Id, str: []const u8) Id {
    var h: u32 = if (seed == 0) fnv_offset else seed;
    for (str) |b| {
        h = (h ^ b) *% fnv_prime;
    }
    return h;
}

pub fn hashInt(seed: Id, value: i64) Id {
    var h: u32 = if (seed == 0) fnv_offset else seed;
    var v: u64 = @bitCast(value);
    var i: usize = 0;
    while (i < 8) : (i += 1) {
        h = (h ^ @as(u8, @truncate(v))) *% fnv_prime;
        v >>= 8;
    }
    return h;
}

/// Options for `getOrPutState`. The `persist` flag enrolls T in
/// the on-disk save/load path — once set on any slot of T, the
/// serializer will include all (id, T) pairs in the next save.
pub const StateOpts = struct {
    /// When true, the typed slot's value gets included in `.zon`
    /// serialization at the next autosave. Requires T to be
    /// auto-serializable (no pointers, no fn pointers) — the
    /// comptime serializer fails to compile otherwise.
    persist: bool = false,
};

/// Curated easing functions for `u.animated`. The full set lives
/// in `src/easings.zig` (25 curves); extension authors needing
/// `bounceOut`, `elasticIn`, etc. can import `easings` directly.
pub const Easing = enum {
    linear,
    ease_in,
    ease_out,
    ease_in_out,
    back,
    elastic,
};

fn applyEasing(e: Easing, t: f32) f32 {
    return switch (e) {
        .linear => easings.linear(t),
        .ease_in => easings.cubicIn(t),
        .ease_out => easings.cubicOut(t),
        .ease_in_out => easings.cubicInOut(t),
        .back => easings.backOut(t),
        .elastic => easings.elasticOut(t),
    };
}

/// Options for `u.animated` — a one-shot tween from `from` to
/// `to` over `duration` seconds, optionally delayed by `delay`
/// seconds before motion starts.
pub const AnimatedOpts = struct {
    from: f32,
    to: f32,
    /// Total motion time in seconds. Must be > 0.
    duration: f32,
    easing: Easing = .linear,
    /// Hold at `from` for this many seconds before starting the
    /// tween. Useful for staggered entrances.
    delay: f32 = 0,
};

/// Internal slot stored via `getOrPutState`. Tracks elapsed time
/// against the tween's duration; the value returned each frame
/// is purely a function of `(elapsed - delay) / duration`.
pub const TweenSlot = struct {
    /// Seconds elapsed since the tween was first created. Resets
    /// when the caller calls `u.animated` with a different `to`
    /// value (i.e. the tween restarts from `from`).
    elapsed: f32 = 0,
    /// Remembered `to` from the previous frame. If the caller
    /// passes a new `to`, the tween resets — common for "fade
    /// IN when hovered, fade OUT when not" patterns where the
    /// caller flips `to` between frames.
    last_to: f32 = 0,
    /// True once the slot has been seeded with `last_to`.
    /// Avoids treating the very first call (where `last_to` is
    /// zero by default) as a `to`-change.
    seeded: bool = false,
};

/// Options for `u.spring` — a critically-damped-by-default
/// physical spring tracking `target`. Adjusting `target`
/// between frames is the normal mode of use; the spring
/// smoothly follows.
pub const SpringOpts = struct {
    target: f32,
    /// Spring stiffness. Higher = snappier. Common range
    /// 50-500. Default 200 feels natural for UI motion.
    stiffness: f32 = 200,
    /// Damping ratio. 1.0 = critical (no overshoot).
    /// 0.5 = bouncy. 2.0 = sluggish.
    damping: f32 = 1.0,
    /// Initial value on the first frame the spring exists.
    /// After that, ignored — the spring's own state takes over.
    initial: f32 = 0,
};

/// Internal slot stored via `getOrPutState`. Spring state is two
/// scalars: current position and current velocity. Each frame the
/// `u.spring` call integrates one step of the spring ODE forward.
pub const SpringSlot = struct {
    value: f32 = 0,
    velocity: f32 = 0,
    /// True after the first `u.spring` call has run; before that,
    /// the slot's `value` gets seeded with `opts.initial`.
    seeded: bool = false,
};

/// Throwaway smoke-test type — `u.miniPlot` is a stub that
/// validates the canvas + arcs + state + style + input +
/// animation pillars compose.  Real `beginPlot` / `endPlot`
/// arrives in Tier 4 and supersedes this; the slot type goes
/// with it.
pub const MiniPlotSlot = struct {
    /// Auto-fit Y-axis limits.  Spring-animated on the public
    /// `currentMin`/`currentMax` values which the renderer
    /// actually uses for the plot's Y mapping.
    targetMin: f32 = 0,
    targetMax: f32 = 1,
    /// True once auto-fit has computed limits from a non-empty
    /// `ys` slice.  Reset to false on R-key press.
    fitted: bool = false,
};

/// Internal: one outer-map entry. Holds a type-erased pointer
/// to the typed `AutoHashMapUnmanaged(Id, T)` for one specific T,
/// plus a function pointer that knows how to deinit + free the
/// typed map when the UiContext tears down.
///
/// The deinit closure has to be stored alongside the map because
/// at UiContext.deinit time we no longer have access to T — only
/// the erased `*anyopaque`. Capturing the deinit fn at first-put
/// time (where T is known via comptime) sidesteps the problem.
/// Type-erased function bound at first persist-true put time to
/// serialize all (id, T) pairs in a typed inner map. Returns
/// owned zon text; caller frees.
const SerializeExtFn = *const fn (
    map: *anyopaque,
    gpa: Allocator,
) anyerror![]u8;

/// Type-erased function bound at first-put time to parse a zon
/// payload back into a typed inner map. Overwrites existing
/// entries (load-time, not merge).
const DeserializeExtFn = *const fn (
    map: *anyopaque,
    gpa: Allocator,
    text: [:0]const u8,
) anyerror!void;

const ExtStorageSlot = struct {
    /// Type-erased pointer to a `*std.AutoHashMapUnmanaged(Id, T)`
    /// allocated on the gpa. Re-cast to the concrete typed map
    /// at every get/put through a comptime-known T.
    map: *anyopaque,
    /// Deinit + free function. Captured at first-put time
    /// (where T is known) as `&deinitErasedMap(T)`. Called by
    /// `UiContext.deinit` walking `ext_storage`.
    deinit_fn: *const fn (map: *anyopaque, gpa: Allocator) void,
    /// True once a `getOrPutState(T,...,.{.persist = true })`
    /// call has fired for this type. `serialize` walks slots with
    /// this flag set and asks each one to encode itself.
    has_persist: bool = false,
    /// Captured `@typeName(T)` of the typed inner map, used to
    /// match persisted-state entries on load. Null until the
    /// first persist-true lookup binds it.
    type_name: ?[]const u8 = null,
    /// Encodes all (id, T) pairs as zon text. Null until the
    /// first persist-true lookup binds it.
    serialize_fn: ?SerializeExtFn = null,
    /// Parses encoded zon text back into the typed inner map.
    /// Null until the first persist-true lookup binds it.
    deserialize_fn: ?DeserializeExtFn = null,
};

/// Comptime-generated key for the outer `ext_storage` map. Hashes
/// the type's name via std.hash.Fnv1a_64. Two types with the same
/// name (which Zig wouldn't allow anyway in the same compilation
/// unit) would collide — practical guarantee is fine.
inline fn extStorageKey(comptime T: type) u64 {
    comptime {
        return std.hash.Fnv1a_64.hash(@typeName(T));
    }
}

/// Comptime-generated deinit for the typed inner map of T.
/// Bound at first-put time, called by UiContext.deinit. Frees
/// the inner-map's own buckets THEN the outer pointer.
fn deinitErasedMap(comptime T: type) (fn (map: *anyopaque, gpa: Allocator) void) {
    return struct {
        fn fun(map: *anyopaque, gpa: Allocator) void {
            const typed: *std.AutoHashMapUnmanaged(Id, T) = @ptrCast(@alignCast(map));
            typed.deinit(gpa);
            gpa.destroy(typed);
        }
    }.fun;
}

/// Comptime-generated serializer for the typed inner map of T.
/// Encodes the live (id, T) pairs as a zon array of
/// `.{.id = u32,.value = T }` tuples and returns the owned
/// bytes. Bound at first-persist-put time.
///
/// **T must be a plain-data type**: structs, arrays, enums, fixed
/// numeric types, BoundedArrays-of-plain-data. Pointers or
/// slices in T will compile but produce garbage at parse time
/// (the slice header survives serialization but points at freed
/// memory after load). Lint rule could enforce this — backlog'd.
fn serializeErasedMap(comptime T: type) SerializeExtFn {
    const Entry = struct { id: u32, value: T };
    return struct {
        fn fun(map: *anyopaque, gpa: Allocator) anyerror![]u8 {
            const typed: *std.AutoHashMapUnmanaged(Id, T) = @ptrCast(@alignCast(map));
            // Walk live entries into a temporary array, then
            // stringify. ArrayList sizing is bounded by the live
            // map count.
            var entries: ArrayList(Entry) = .empty;
            defer entries.deinit(gpa);
            var it = typed.iterator();
            while (it.next()) |kv| {
                try entries.append(gpa, .{ .id = kv.key_ptr.*, .value = kv.value_ptr.* });
            }
            var out: std.Io.Writer.Allocating = .init(gpa);
            errdefer out.deinit();
            try std.zon.stringify.serialize(entries.items, .{}, &out.writer);
            return try out.toOwnedSlice();
        }
    }.fun;
}

/// Comptime-generated deserializer for the typed inner map of T.
/// Parses zon text produced by `serializeErasedMap(T)` and
/// overwrites the typed map's entries. Existing entries with
/// matching ids are replaced; unmatched live entries are left
/// alone (load is union-with-incoming, not replacement).
fn deserializeErasedMap(comptime T: type) DeserializeExtFn {
    const Entry = struct { id: u32, value: T };
    return struct {
        fn fun(map: *anyopaque, gpa: Allocator, text: [:0]const u8) anyerror!void {
            const typed: *std.AutoHashMapUnmanaged(Id, T) = @ptrCast(@alignCast(map));
            var diag: std.zon.parse.Diagnostics = .{};
            defer diag.deinit(gpa);
            // Parse as a slice of Entry — that's the wire shape
            // serializeErasedMap emits.
            const parsed = try std.zon.parse.fromSliceAlloc(
                []Entry,
                gpa,
                text,
                &diag,
                .{ .ignore_unknown_fields = true },
            );
            defer std.zon.parse.free(gpa, parsed);
            for (parsed) |e| {
                try typed.put(gpa, e.id, e.value);
            }
        }
    }.fun;
}

/// Per-widget RGBA color, stored packed in u32 for the renderer's
/// vertex format. ImGui uses 0xAABBGGRR (little-endian); we match
/// to keep the wire format identical.
const ColorU32 = zm.ColorU32;

/// Pack a `Color` into the renderer's u32 format.
/// Multiply the alpha channel of a packed `ColorU32` by `mul` in
/// [0, 1], clamping the result. Used by the draw helpers to apply
/// `ctx.alpha_mul` (the disabled-scope dimming) to every painted
/// rect/text without invalidating colour cache hits at call sites.
pub inline fn multiplyAlpha(col: ColorU32, mul: f32) ColorU32 {
    if (mul >= 1.0) {
        return col;
    }
    const a: u32 = (col >> 24) & 0xFF;
    const a_f: f32 = float(a);
    const a_new: u32 = @round(clamp(a_f * mul, 0, 255));
    return (col & 0x00FFFFFF) | (a_new << 24);
}

/// Resolve the width a widget should use for its primary input
/// element. Priority (high to low):
///   1. `next_item_width` (set via `setNextItemWidth`; consumed)
///   2. top of `item_width_stack` (set via `pushItemWidth`)
///   3. `opts_width` (per-call-site option), if > 0
///   4. `default_w` (the widget's natural width if nothing else fires)
/// `next_item_width` is cleared on read - the override is one-shot.
pub fn effectiveItemWidth(
    ctx: *UiContext,
    opts_width: f32,
    default_w: f32,
) f32 {
    if (ctx.next_item_width) |w| {
        ctx.next_item_width = null;
        return w;
    }
    if (ctx.item_width_stack.top()) |w| {
        return w;
    }
    if (opts_width > 0) {
        return opts_width;
    }
    return default_w;
}

/// Tailwind palette hues that have a `_500` shade in `z.colors`.
/// Used by `Style.applyTailwind` for one-call retheming.
pub const TailwindHue = enum {
    slate,
    sky,
    amber,
    red,
    pink,
    green,
    emerald,
    rose,
    violet,
};

/// Built-in theme presets selectable via `Style.applyPreset`.
/// Matches imgui's `ImGui::StyleColors{Dark,Light,Classic}()`.
pub const Preset = enum {
    /// `Style.dark_default` — the project's standard, ships in
    /// `UiContext.init`. Equivalent to imgui's `StyleColorsDark`.
    dark,
    /// `Style.light_default` — light backgrounds, dark text.
    /// Equivalent to imgui's `StyleColorsLight`.
    light,
    /// `Style.classic_default` — the original imgui look, blue-purple
    /// chrome on black. Equivalent to imgui's `StyleColorsClassic`.
    classic,
};

/// Reasonable subset of ImGui's `ImGuiStyle`. only uses the
/// colors and a few padding values; the rest are placeholders so
/// future widgets can read them without a follow-up patch.
pub const Style = struct {
    // Colors. Each named slot mirrors an `ImGuiCol_*` enum.
    text: Color,
    text_disabled: Color,
    window_bg: Color,
    border: Color,
    title_bg: Color,
    title_bg_active: Color,
    button: Color,
    button_hovered: Color,
    button_active: Color,
    frame_bg: Color,
    frame_bg_hovered: Color,
    frame_bg_active: Color,
    // scrollbar slots - track + thumb (idle/hovered/active).
    // Mirrors ImGui's ImGuiCol_ScrollbarBg / Grab / GrabHovered / GrabActive.
    scrollbar_bg: Color,
    scrollbar_grab: Color,
    scrollbar_grab_hovered: Color,
    scrollbar_grab_active: Color,
    // popup chrome (background + modal-backdrop dim).
    // ImGui: ImGuiCol_PopupBg / ModalWindowDimBg.
    popup_bg: Color,
    modal_dim_bg: Color,
    /// main menu bar strip background + hover colour
    /// applied to a `beginMenu` button when the user mouses over
    /// it. ImGui: ImGuiCol_MenuBarBg / HeaderHovered (close enough).
    menu_bar_bg: Color,
    menu_hovered: Color,
    /// tab chrome - idle/hovered/active button colours +
    /// the line that separates the bar from the content below.
    /// ImGui: Tab / TabHovered / TabSelected / TabSelectedOverline.
    tab: Color,
    tab_hovered: Color,
    tab_active: Color,
    tab_separator: Color,
    /// (tables): row background colors when
    /// `TableOpts.row_bg` is enabled. Even rows get
    /// `table_row_bg`, odd rows get `table_row_bg_alt`. Both
    /// default to subtle semi-transparent tints so they read as
    /// "striping" rather than fighting the cell content. Mirrors
    /// ImGui's `ImGuiCol_TableRowBg` / `ImGuiCol_TableRowBgAlt`.
    table_row_bg: Color,
    table_row_bg_alt: Color,

    /// (text helpers): three explicit slots for text-link
    /// rendering. imgui ships ONE slot (`ImGuiCol_TextLink`) and derives
    /// the hovered + underline variants via HSV gymnastics at draw time
    /// (ocornut's own comment in imgui_widgets.cpp:1544 flags this as
    /// "not written in the same style as some earlier widgets"). We
    /// ship the three colours as separate Style fields so the theme
    /// reads exactly as authored - no hidden HSV-shift precedence
    /// inside the widget. Matches how `button` has its three slots.
    text_link: Color,
    text_link_hovered: Color,
    text_link_underline: Color,

    // Sizes.
    window_padding: Vec2 = .{ 10, 10 }, // was .{ 8, 8 }
    frame_padding: Vec2 = .{ 6, 4 }, // was .{ 4, 3 }
    item_spacing: Vec2 = .{ 10, 6 }, // was .{ 8, 4 }
    title_bar_height: f32 = 28, // was 22
    menu_bar_height: f32 = 28, // was 22

    font_size: f32 = 16, // was 10

    // window_padding: Vec2 = .{ 8, 8 },
    // frame_padding: Vec2 = .{ 4, 3 },
    // item_spacing: Vec2 = .{ 8, 4 },
    // title_bar_height: f32 = 22,
    // /// Height of the main menu bar strip at top of canvas.
    /// raylib parity convention: 22px matches title bar so the two
    /// chrome elements stack visually.
    // menu_bar_height: f32 = 22,

    // alpha multiplier applied to widget rendering when
    // inside a `beginDisabled` scope. Matches ImGui's
    // `Style.DisabledAlpha = 0.6f` default.
    disabled_alpha: f32 = 0.6,

    // ---- Typography (Tier B: font integration)
    // `font == null` means "use the default bitmap" - no allocation,
    // no asset dependency, what every example has used so far. Set
    // a real font to retheme typography:
    //     const roboto = try z.text.loadFont(@embedFile("RobotoMono-Regular.ttf"), 14);
    //     f.ui.style().font = &roboto;
    //     f.ui.style().font_size = 14;
    // The pointer is *const so the caller can keep the Font in
    // global state without worrying about Style mutating it.

    /// Override font for all UI text. `null` = default bitmap.
    font: ?*const Font = null,
    /// Pixel height to render UI text at. 10 matches the legacy
    /// bitmap font's pixel height; raise to 12-16 for TTFs.
    // font_size: f32 = 10,
    /// Horizontal pixel padding between glyphs when using a custom
    /// font (matches raylib's `drawTextEx` `spacing` parameter).
    /// Ignored for the default bitmap.
    font_spacing: f32 = 0,
    /// Vertical pixel gap between lines for any UI text containing
    /// '\n'. Forwarded to `text2d.drawWithFont` / `measureEx` from
    /// `measureTextS` and `drawTextAtS`. 2 px is the historical
    /// raylib default. This is shared *style* (the user picks
    /// once) - the runtime-level `line_spacing` setting is gone.
    line_spacing: i32 = 2,

    // Defaults. Matches ImGui::StyleColorsDark() - close enough that
    // the visual result reads as "yes, that's ImGui."
    pub const dark_default: Style = .{
        .text = .{ .r = 230, .g = 230, .b = 230, .a = 255 },
        .text_disabled = .{ .r = 128, .g = 128, .b = 128, .a = 255 },
        .window_bg = .{ .r = 15, .g = 15, .b = 15, .a = 240 },
        .border = .{ .r = 110, .g = 110, .b = 128, .a = 128 },
        .title_bg = .{ .r = 10, .g = 10, .b = 10, .a = 255 },
        .title_bg_active = .{ .r = 41, .g = 74, .b = 122, .a = 255 },
        .button = .{ .r = 66, .g = 150, .b = 250, .a = 102 },
        .button_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 255 },
        .button_active = .{ .r = 15, .g = 135, .b = 250, .a = 255 },
        .frame_bg = .{ .r = 41, .g = 74, .b = 122, .a = 138 },
        .frame_bg_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 102 },
        .frame_bg_active = .{ .r = 66, .g = 150, .b = 250, .a = 171 },
        // Scrollbar - neutral grey track, accent-tinted thumb.
        // Matches ImGui dark-theme defaults closely.
        .scrollbar_bg = .{ .r = 5, .g = 5, .b = 5, .a = 135 },
        .scrollbar_grab = .{ .r = 79, .g = 79, .b = 79, .a = 255 },
        .scrollbar_grab_hovered = .{ .r = 105, .g = 105, .b = 105, .a = 255 },
        .scrollbar_grab_active = .{ .r = 130, .g = 130, .b = 130, .a = 255 },
        // Popup background (slightly lighter than window_bg for
        // visual lift) + modal backdrop dim (semi-transparent
        // black overlaying the parent content).
        .popup_bg = .{ .r = 20, .g = 20, .b = 20, .a = 240 },
        .modal_dim_bg = .{ .r = 35, .g = 40, .b = 50, .a = 88 },
        // Menu bar - slightly darker than title bar; menu hover
        // matches button hover for consistency.
        .menu_bar_bg = .{ .r = 30, .g = 30, .b = 35, .a = 255 },
        .menu_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 180 },
        // Tab chrome - ImGui defaults. Idle tabs darker than
        // window bg; active tab matches title_bg_active for
        // visual continuity with "this is the current view".
        .tab = .{ .r = 46, .g = 89, .b = 148, .a = 219 },
        .tab_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 204 },
        .tab_active = .{ .r = 51, .g = 105, .b = 173, .a = 255 },
        .tab_separator = .{ .r = 110, .g = 110, .b = 130, .a = 255 },
        // Table row striping - even rows fully transparent (so the
        // window bg shows through), odd rows a faint white tint.
        // ImGui values verbatim.
        .table_row_bg = .{ .r = 0, .g = 0, .b = 0, .a = 0 },
        .table_row_bg_alt = .{ .r = 255, .g = 255, .b = 255, .a = 15 },
        // Text-link colours. Base matches imgui's `ImGuiCol_TextLink`
        // dark-default (soft mid-blue, ~RGB 102/159/218). Hovered is
        // the same hue brightened - reads as "you're about to click
        // me." Underline is the same hue darkened - reads as a
        // subtle "beneath the line" stroke without competing.
        .text_link = .{ .r = 102, .g = 159, .b = 218, .a = 255 },
        .text_link_hovered = .{ .r = 140, .g = 195, .b = 240, .a = 255 },
        .text_link_underline = .{ .r = 80, .g = 130, .b = 180, .a = 255 },
    };

    /// Re-derive the interactive colors from a single accent color.
    /// Structural chrome (text, borders, window bg, base title bar)
    /// stays unchanged - that's neutral by design and shouldn't bend
    /// to the accent. Modeled on `ImGui::StyleColorsDark()`'s ratios:
    /// button at 40% alpha, hovered at full, active darkened 20%, etc.
    ///     f.ui.style().applyAccent(z.colors.amber_500);
    ///     // every interactive widget now reads as "amber"
    pub fn applyAccent(self: *Style, accent: Color) void {
        self.button = accent.fade(0.40);
        self.button_hovered = accent.fade(1.00);
        self.button_active = accent.brightness(0.80).fade(1.00);
        // Slider bar / checkbox bg / inputText box - sit below button
        // intensity so the active widget reads on top of them.
        self.frame_bg = accent.brightness(0.60).fade(0.54);
        self.frame_bg_hovered = accent.fade(0.40);
        self.frame_bg_active = accent.fade(0.67);
        // Active window's title bar takes the accent darkened so it
        // reads as "this window's chrome, in the brand color."
        self.title_bg_active = accent.brightness(0.50).fade(1.00);
    }

    /// One-call retheme using a Tailwind-CSS-named hue at the 500
    /// shade (Tailwind's standard "primary" weight - bright enough
    /// to read as the brand color, not so saturated it fights the
    /// chrome). Add hues here as we add them to `z.colors`; the
    /// 9 currently in the palette are listed.
    pub fn applyTailwind(self: *Style, hue: TailwindHue) void {
        const accent: Color = switch (hue) {
            .slate => types.slate_500,
            .sky => types.sky_500,
            .amber => types.amber_500,
            .red => types.red_500,
            .pink => types.pink_500,
            .green => types.green_500,
            .emerald => types.emerald_500,
            .rose => types.rose_500,
            .violet => types.violet_500,
        };
        self.applyAccent(accent);
    }

    // ---- Theme presets -------------------------------
    // imgui ships three default themes via `StyleColors{Dark,Light,Classic}`.
    // zimr's `dark_default` (above) is the port of `StyleColorsDark`.
    // These two add the Light + Classic counterparts — translated from
    // imgui's float (0..1) form to zimr's u8 form via `round(x*255)`.
    //

    // Three differences from a literal imgui port:
    //   1. zimr's `Style` has 3 text-link slots (link / hovered /
    //      underline) where imgui has 1. We derive `hovered` and
    //      `underline` from the base link colour using the same
    //      brighten / darken offsets that `dark_default` uses (eyeballed
    //      to read as "obvious hover" / "subtle stroke").
    //   2. `tab_separator` doesn't have a direct imgui counterpart;
    //      pick `Separator` (StyleColors[Separator]) which is
    //      structurally the same chrome divider. imgui's value for
    //      Separator in the light theme is (0.39, 0.39, 0.39, 0.62);
    //      Classic is (0.50, 0.50, 0.50, 0.50).
    //   3. `menu_hovered` maps to imgui's `HeaderHovered` per the
    //      `dark_default` precedent set in `Style.dark_default`.

    pub const light_default: Style = .{
        .text = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
        .text_disabled = .{ .r = 153, .g = 153, .b = 153, .a = 255 },
        .window_bg = .{ .r = 240, .g = 240, .b = 240, .a = 255 },
        .border = .{ .r = 0, .g = 0, .b = 0, .a = 77 },
        .title_bg = .{ .r = 245, .g = 245, .b = 245, .a = 255 },
        .title_bg_active = .{ .r = 209, .g = 209, .b = 209, .a = 255 },
        .button = .{ .r = 66, .g = 150, .b = 250, .a = 102 },
        .button_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 255 },
        .button_active = .{ .r = 15, .g = 135, .b = 250, .a = 255 },
        .frame_bg = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
        .frame_bg_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 102 },
        .frame_bg_active = .{ .r = 66, .g = 150, .b = 250, .a = 171 },
        .scrollbar_bg = .{ .r = 250, .g = 250, .b = 250, .a = 135 },
        .scrollbar_grab = .{ .r = 176, .g = 176, .b = 176, .a = 204 },
        .scrollbar_grab_hovered = .{ .r = 125, .g = 125, .b = 125, .a = 204 },
        .scrollbar_grab_active = .{ .r = 125, .g = 125, .b = 125, .a = 255 },
        .popup_bg = .{ .r = 255, .g = 255, .b = 255, .a = 250 },
        .modal_dim_bg = .{ .r = 51, .g = 51, .b = 51, .a = 89 },
        .menu_bar_bg = .{ .r = 219, .g = 219, .b = 219, .a = 255 },
        .menu_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 204 },
        .tab = .{ .r = 194, .g = 204, .b = 214, .a = 237 },
        .tab_hovered = .{ .r = 66, .g = 150, .b = 250, .a = 204 },
        .tab_active = .{ .r = 153, .g = 186, .b = 224, .a = 255 },
        .tab_separator = .{ .r = 99, .g = 99, .b = 99, .a = 158 },
        .table_row_bg = .{ .r = 0, .g = 0, .b = 0, .a = 0 },
        .table_row_bg_alt = .{ .r = 77, .g = 77, .b = 77, .a = 23 },
        // Text link: imgui (0.26, 0.59, 0.98, 1.00) → 66, 150, 250.
        // Hovered: brighten ~30%, underline: darken ~20%.
        .text_link = .{ .r = 66, .g = 150, .b = 250, .a = 255 },
        .text_link_hovered = .{ .r = 110, .g = 180, .b = 255, .a = 255 },
        .text_link_underline = .{ .r = 50, .g = 120, .b = 200, .a = 255 },
    };

    pub const classic_default: Style = .{
        .text = .{ .r = 230, .g = 230, .b = 230, .a = 255 },
        .text_disabled = .{ .r = 153, .g = 153, .b = 153, .a = 255 },
        .window_bg = .{ .r = 0, .g = 0, .b = 0, .a = 217 },
        .border = .{ .r = 179, .g = 179, .b = 179, .a = 166 },
        .title_bg = .{ .r = 69, .g = 69, .b = 138, .a = 212 },
        .title_bg_active = .{ .r = 82, .g = 82, .b = 161, .a = 222 },
        .button = .{ .r = 89, .g = 102, .b = 156, .a = 158 },
        .button_hovered = .{ .r = 102, .g = 122, .b = 181, .a = 201 },
        .button_active = .{ .r = 117, .g = 138, .b = 204, .a = 255 },
        .frame_bg = .{ .r = 110, .g = 110, .b = 110, .a = 99 },
        .frame_bg_hovered = .{ .r = 120, .g = 120, .b = 176, .a = 102 },
        .frame_bg_active = .{ .r = 107, .g = 105, .b = 163, .a = 176 },
        .scrollbar_bg = .{ .r = 51, .g = 64, .b = 77, .a = 153 },
        .scrollbar_grab = .{ .r = 102, .g = 102, .b = 204, .a = 77 },
        .scrollbar_grab_hovered = .{ .r = 102, .g = 102, .b = 204, .a = 102 },
        .scrollbar_grab_active = .{ .r = 105, .g = 99, .b = 204, .a = 153 },
        .popup_bg = .{ .r = 28, .g = 28, .b = 36, .a = 235 },
        .modal_dim_bg = .{ .r = 51, .g = 51, .b = 51, .a = 89 },
        .menu_bar_bg = .{ .r = 102, .g = 102, .b = 140, .a = 204 },
        .menu_hovered = .{ .r = 102, .g = 102, .b = 230, .a = 115 },
        .tab = .{ .r = 69, .g = 69, .b = 115, .a = 212 },
        .tab_hovered = .{ .r = 105, .g = 105, .b = 156, .a = 255 },
        .tab_active = .{ .r = 87, .g = 87, .b = 173, .a = 255 },
        .tab_separator = .{ .r = 128, .g = 128, .b = 128, .a = 128 },
        .table_row_bg = .{ .r = 0, .g = 0, .b = 0, .a = 0 },
        .table_row_bg_alt = .{ .r = 255, .g = 255, .b = 255, .a = 18 },
        // Text link: imgui (0.40, 0.48, 0.71, 1.00) → 102, 122, 181.
        .text_link = .{ .r = 102, .g = 122, .b = 181, .a = 255 },
        .text_link_hovered = .{ .r = 140, .g = 160, .b = 220, .a = 255 },
        .text_link_underline = .{ .r = 80, .g = 95, .b = 140, .a = 255 },
    };

    /// One-call theme switcher. Replaces every Color slot with the
    /// preset's values; leaves the numeric chrome (padding, spacing,
    /// font*) untouched so the user's typography/density choices
    /// survive a theme flip.
    ///     ui.ctx.style.applyPreset(.light);
    /// Pair with `applyTailwind` for "neutral theme + brand accent":
    ///     ui.ctx.style.applyPreset(.light);
    ///     ui.ctx.style.applyTailwind(.emerald);
    pub fn applyPreset(self: *Style, preset: Preset) void {
        const src: Style = switch (preset) {
            .dark => Style.dark_default,
            .light => Style.light_default,
            .classic => Style.classic_default,
        };
        // Copy colors only — preserve user's numeric chrome + font.
        self.text = src.text;
        self.text_disabled = src.text_disabled;
        self.window_bg = src.window_bg;
        self.border = src.border;
        self.title_bg = src.title_bg;
        self.title_bg_active = src.title_bg_active;
        self.button = src.button;
        self.button_hovered = src.button_hovered;
        self.button_active = src.button_active;
        self.frame_bg = src.frame_bg;
        self.frame_bg_hovered = src.frame_bg_hovered;
        self.frame_bg_active = src.frame_bg_active;
        self.scrollbar_bg = src.scrollbar_bg;
        self.scrollbar_grab = src.scrollbar_grab;
        self.scrollbar_grab_hovered = src.scrollbar_grab_hovered;
        self.scrollbar_grab_active = src.scrollbar_grab_active;
        self.popup_bg = src.popup_bg;
        self.modal_dim_bg = src.modal_dim_bg;
        self.menu_bar_bg = src.menu_bar_bg;
        self.menu_hovered = src.menu_hovered;
        self.tab = src.tab;
        self.tab_hovered = src.tab_hovered;
        self.tab_active = src.tab_active;
        self.tab_separator = src.tab_separator;
        self.table_row_bg = src.table_row_bg;
        self.table_row_bg_alt = src.table_row_bg_alt;
        self.text_link = src.text_link;
        self.text_link_hovered = src.text_link_hovered;
        self.text_link_underline = src.text_link_underline;
    }
};

// Style override guard — defer-RAII for one-frame style changes.
//

// Pattern:
//     {
//         const g = u.styleOverride(.{.text = my_red });
//         defer g.restore();
//         u.text("warning",.{}); // red
//     }
//     // back to previous text color
//

// Why defer-RAII over imgui's pushStyleColor/popStyleColor stack?
// Two reasons: (1) Zig's `defer` already exists, no need for a
// custom stack data structure; (2) override+restore is symmetric
// at the source level — pop misuse (forgetting one, popping the
// wrong type) is impossible. Trade-off: one PartialStyle worth
// of stack memory per active guard (~64 bytes today). Acceptable.
//

// Comptime-derived: `PartialStyle` is generated via
// `inline for (fields(Style))` to map each Color/f32/Vec2 field
// to an optional of the same type. Adding a new field to Style
// automatically extends PartialStyle without further edits.

/// Comptime-derived "optional override" version of `Style`. Each
/// field is `?T` of the original field type — null means "leave
/// the live value alone". Caller fills in only the fields they
/// want to override:
///
///     const overrides: PartialStyle =.{.text = my_red };
pub const PartialStyle = blk: {
    const s_names = @typeInfo(Style).@"struct".field_names;
    const s_types = @typeInfo(Style).@"struct".field_types;
    const N: usize = s_names.len;
    var ps_names: [N][:0]const u8 = undefined;
    var ps_types: [N]type = undefined;
    var ps_attrs: [N]std.builtin.Type.Struct.FieldAttributes = undefined;
    for (s_names, s_types, 0..) |f_name, f_type, i| {
        ps_names[i] = f_name;
        ps_types[i] = ?f_type;
        // Each optional field defaults to null so a caller can
        // omit it in the struct literal: `.{.text = my_red }`
        // works without listing every other field.
        const default: ?f_type = null;
        ps_attrs[i] = .{ .default_value_ptr = @ptrCast(&default) };
    }
    break :blk @Struct(.auto, null, &ps_names, &ps_types, &ps_attrs);
};

/// Returned by `styleOverride`. Holds the prior values of just
/// the fields the caller actually overrode, so `restore` can put
/// them back. Memory footprint = size of one PartialStyle (one
/// optional per Style field). Use with `defer g.restore()`.
pub const StyleGuard = struct {
    ctx: *UiContext,
    saved: PartialStyle,

    /// Restore prior values for every field the matching
    /// `styleOverride` call modified. Safe to call exactly once;
    /// calling again is a no-op (all saved values were already
    /// written back, but no harm in re-writing them — the
    /// idempotence test verifies this).
    pub fn restore(self: StyleGuard) void {
        inline for (@typeInfo(Style).@"struct".field_names) |f_name| {
            if (@field(self.saved, f_name)) |prior| {
                @field(self.ctx.style, f_name) = prior;
            }
        }
    }
};

/// Dense zimr-side key enum. Mirrors raylib's `KeyboardKey`
/// (`types.zig:819`) but **dense** — every value in `0..MAX` is a
/// valid KeyCode, so it indexes a fixed-size array on
/// `InputSnapshot.keys` without holes. raylib's KeyboardKey has
/// sparse values (e.g. space=32, escape=256, f1=290) that would
/// need a 348-slot array with mostly empty entries; this dense
/// remapping keeps the snapshot compact.
///
/// `fromRaylib(types.KeyboardKey) → ?KeyCode` translates the
/// runtime input layer into zimr's key index. Keys raylib
/// supports but zimr doesn't surface return null and are skipped
/// during snapshot construction.
///
/// `MAX` is the count sentinel — `@intFromEnum(KeyCode.MAX)`
/// gives the array size. Do not pass `MAX` to `u.isKeyDown` —
/// it's a count, not a key.
pub const KeyCode = enum(u8) {
    // Letters
    a,
    b,
    c,
    d,
    e,
    f,
    g,
    h,
    i,
    j,
    k,
    l,
    m,
    n,
    o,
    p,
    q,
    r,
    s,
    t,
    u,
    v,
    w,
    x,
    y,
    z,
    // Digits (top row)
    zero,
    one,
    two,
    three,
    four,
    five,
    six,
    seven,
    eight,
    nine,
    // Function keys
    f1,
    f2,
    f3,
    f4,
    f5,
    f6,
    f7,
    f8,
    f9,
    f10,
    f11,
    f12,
    // Arrow keys
    left,
    right,
    up,
    down,
    // Navigation cluster
    home,
    end,
    page_up,
    page_down,
    insert,
    delete,
    // Action keys
    enter,
    escape,
    tab,
    backspace,
    space,
    // Modifiers (tracked individually; convenience accessors below)
    left_shift,
    right_shift,
    left_control,
    right_control,
    left_alt,
    right_alt,
    left_super,
    right_super,
    // Punctuation
    apostrophe,
    comma,
    minus,
    period,
    slash,
    semicolon,
    equal,
    left_bracket,
    right_bracket,
    backslash,
    grave,
    // Keypad
    kp_0,
    kp_1,
    kp_2,
    kp_3,
    kp_4,
    kp_5,
    kp_6,
    kp_7,
    kp_8,
    kp_9,
    kp_decimal,
    kp_divide,
    kp_multiply,
    kp_subtract,
    kp_add,
    kp_enter,
    kp_equal,
    // Lock keys
    caps_lock,
    num_lock,
    scroll_lock,
    print_screen,
    pause,
    // Sentinel — NOT a key, just the count of preceding enum values.
    // Used as the size of `InputSnapshot.keys`.
    MAX,

    /// Translate a raylib `KeyboardKey` to a zimr `KeyCode`.
    /// Returns `null` for raylib keys zimr doesn't surface
    /// (e.g. `kb_menu`, `null` sentinel).
    /// Translate a raylib `KeyboardKey` to a zimr `KeyCode`.
    /// Returns `null` for raylib keys zimr doesn't surface
    /// (e.g. `kb_menu`, `null` sentinel).
    pub fn fromRaylib(rk: types.KeyboardKey) ?KeyCode {
        return switch (rk) {
            .a => .a,
            .b => .b,
            .c => .c,
            .d => .d,
            .e => .e,
            .f => .f,
            .g => .g,
            .h => .h,
            .i => .i,
            .j => .j,
            .k => .k,
            .l => .l,
            .m => .m,
            .n => .n,
            .o => .o,
            .p => .p,
            .q => .q,
            .r => .r,
            .s => .s,
            .t => .t,
            .u => .u,
            .v => .v,
            .w => .w,
            .x => .x,
            .y => .y,
            .z => .z,
            .zero => .zero,
            .one => .one,
            .two => .two,
            .three => .three,
            .four => .four,
            .five => .five,
            .six => .six,
            .seven => .seven,
            .eight => .eight,
            .nine => .nine,
            .f1 => .f1,
            .f2 => .f2,
            .f3 => .f3,
            .f4 => .f4,
            .f5 => .f5,
            .f6 => .f6,
            .f7 => .f7,
            .f8 => .f8,
            .f9 => .f9,
            .f10 => .f10,
            .f11 => .f11,
            .f12 => .f12,
            .left => .left,
            .right => .right,
            .up => .up,
            .down => .down,
            .home => .home,
            .end => .end,
            .page_up => .page_up,
            .page_down => .page_down,
            .insert => .insert,
            .delete => .delete,
            .enter => .enter,
            .escape => .escape,
            .tab => .tab,
            .backspace => .backspace,
            .space => .space,
            .left_shift => .left_shift,
            .right_shift => .right_shift,
            .left_control => .left_control,
            .right_control => .right_control,
            .left_alt => .left_alt,
            .right_alt => .right_alt,
            .left_super => .left_super,
            .right_super => .right_super,
            .apostrophe => .apostrophe,
            .comma => .comma,
            .minus => .minus,
            .period => .period,
            .slash => .slash,
            .semicolon => .semicolon,
            .equal => .equal,
            .left_bracket => .left_bracket,
            .right_bracket => .right_bracket,
            .backslash => .backslash,
            .grave => .grave,
            .kp_0 => .kp_0,
            .kp_1 => .kp_1,
            .kp_2 => .kp_2,
            .kp_3 => .kp_3,
            .kp_4 => .kp_4,
            .kp_5 => .kp_5,
            .kp_6 => .kp_6,
            .kp_7 => .kp_7,
            .kp_8 => .kp_8,
            .kp_9 => .kp_9,
            .kp_decimal => .kp_decimal,
            .kp_divide => .kp_divide,
            .kp_multiply => .kp_multiply,
            .kp_subtract => .kp_subtract,
            .kp_add => .kp_add,
            .kp_enter => .kp_enter,
            .kp_equal => .kp_equal,
            .caps_lock => .caps_lock,
            .num_lock => .num_lock,
            .scroll_lock => .scroll_lock,
            .print_screen => .print_screen,
            .pause => .pause,
            else => null, // .null, .kb_menu, anything zimr doesn't surface
        };
    }

    /// Inverse of `fromRaylib` — every `KeyCode` (except `MAX`)
    /// maps to a `types.KeyboardKey` of the same field name.
    /// Used by repeat-aware helpers that need to call back into
    /// the raylib input layer (which owns the OS-level repeat
    /// timer). Calling this on `KeyCode.MAX` is a logic error and
    /// will fail at compile time via the inline-for guard.
    pub fn toRaylib(kc: KeyCode) types.KeyboardKey {
        return switch (kc) {
            .MAX => unreachable,
            inline else => |tag| @field(types.KeyboardKey, @tagName(tag)),
        };
    }
};

/// Per-key state, indexed by `KeyCode` in `InputSnapshot.keys`.
/// Mirrors imgui's `ImGuiKeyData` (imgui.h:2481-2487) plus a
/// `pressed_this_frame` / `released_this_frame` pair zimr uses for
/// the common edge-event pattern. Memory: ~12 bytes per key ×
/// `KeyCode.MAX` ≈ 1.5 KB per snapshot.
pub const KeyState = struct {
    /// True while the key is being held.
    down: bool = false,
    /// True for exactly one frame: the frame the key transitioned
    /// from up to down. Edge event.
    pressed_this_frame: bool = false,
    /// True for exactly one frame: the frame the key transitioned
    /// from down to up. Edge event.
    released_this_frame: bool = false,
    /// Frame count the key has been held. 0 the frame of press,
    /// increments each subsequent frame. Reset on release.
    down_duration_frames: u32 = 0,
};

/// Snapshot of the input state at the start of a frame. The Ui
/// reads this rather than reaching back into `Frame.input` directly
/// - keeps the dependency graph clean (ui.zig doesn't import
/// runtime.zig) and makes head-less testing trivial.
pub const InputSnapshot = struct {
    mouse_pos: Vec2 = .{ -10000, -10000 }, // off-screen sentinel
    mouse_left_down: bool = false,
    mouse_left_clicked: bool = false, // pressed this frame
    mouse_left_released: bool = false, // released this frame
    /// Right-mouse-button click - pressed-this-frame edge event.
    /// Used by `beginPopupContextItem` to auto-open a context menu
    /// over the previous widget when the user right-clicks it.
    mouse_right_clicked: bool = false,
    /// Middle-mouse-button click - pressed-this-frame edge event.
    /// Used by `beginTabItem` for middle-click-to-close, the
    /// standard browser-tab interaction.
    mouse_middle_clicked: bool = false,
    /// Vertical mouse-wheel delta this frame. Positive = wheel
    /// scrolled up (away from user); negative = down. Used by
    /// hovered windows to translate scroll_y.
    mouse_wheel_y: f32 = 0,
    /// Horizontal mouse-wheel delta this frame (trackpad two-finger
    /// horizontal swipe, or shift-scroll on a wheel mouse). Positive
    /// = right; negative = left. Used by hovered windows that have
    /// `WindowFlags.horizontal_scrollbar` set to translate scroll_x.
    /// P5.5b.
    mouse_wheel_x: f32 = 0,
    /// Number of fingers currently down (0 on desktop). Consumers read 2+ to
    /// detect a pinch; a single touch already drives the mouse_* fields.
    touch_count: i32 = 0,
    /// Screen positions of the first two touch points (valid when
    /// touch_count >= 1 / >= 2 respectively). Used for pinch-zoom.
    touch_pos: [2]Vec2 = .{ .{ 0, 0 }, .{ 0, 0 } },
    delta_time: f32 = 0,

    // ---- Keyboard state for inputText -----------------------
    /// Codepoints typed this frame. ASCII for now; the framework
    /// already gives us codepoints from `getCharPressed`. Fixed
    /// capacity keeps the snapshot a value type. Excess chars in a
    /// frame (>16) are dropped, which never happens in practice.
    chars_typed: [16]u32 = undefined,
    chars_typed_count: usize = 0,

    // Dense per-frame key-state array indexed by `KeyCode`. Tests
    // use `keyEventSnapshot(.X)` to construct synthetic input.
    // Mirrors imgui's `ImGuiIO::KeysData[ImGuiKey_NamedKey_COUNT]`
    // (imgui.h:2487-2690) but with zimr's own dense KeyCode enum.
    keys: [@intFromEnum(KeyCode.MAX)]KeyState = @splat(.{}),
};

/// Test helper: returns an `InputSnapshot` with a single
/// pressed-this-frame edge event for `key` set. Used by host-
/// side tests that inject synthetic input via `beginFrameRaw`.
/// Production code populates the snapshot via the inline-for loop
/// in `beginFrame` — this helper exists only for tests.
pub fn keyEventSnapshot(key: KeyCode) InputSnapshot {
    var s: InputSnapshot = .{};
    s.keys[@intFromEnum(key)].pressed_this_frame = true;
    s.keys[@intFromEnum(key)].down = true;
    return s;
}

/// Convenience: held-state modifier checks that aggregate left+right
/// sides. Same shape as the `Ui.isShiftDown` / `isCtrlDown` /
/// `isAltDown` / `isSuperDown` helpers but takes a snapshot
/// directly — used inside widget impl code that has a `*UiContext`
/// but no `Ui` handle. Resolves to the same array lookup.
pub fn snapshotShiftDown(s: *const InputSnapshot) bool {
    return s.keys[@intFromEnum(KeyCode.left_shift)].down or
        s.keys[@intFromEnum(KeyCode.right_shift)].down;
}
pub fn snapshotCtrlDown(s: *const InputSnapshot) bool {
    return s.keys[@intFromEnum(KeyCode.left_control)].down or
        s.keys[@intFromEnum(KeyCode.right_control)].down;
}
pub fn snapshotAltDown(s: *const InputSnapshot) bool {
    return s.keys[@intFromEnum(KeyCode.left_alt)].down or
        s.keys[@intFromEnum(KeyCode.right_alt)].down;
}
pub fn snapshotSuperDown(s: *const InputSnapshot) bool {
    return s.keys[@intFromEnum(KeyCode.left_super)].down or
        s.keys[@intFromEnum(KeyCode.right_super)].down;
}

/// `Ui.isKeyPressedOrRepeat` for callers that have a `*UiContext`
/// but no `Ui` handle — same semantics, no construction ceremony.
/// Initial-press case reads `keys[i].pressed_this_frame`; held-
/// and-repeating case routes through raylib's OS-level timer.
pub fn ctxKeyPressedOrRepeat(ctx: *UiContext, key: KeyCode) bool {
    if (ctx.input.keys[@intFromEnum(key)].pressed_this_frame) {
        return true;
    }
    const s: *runtime.input.InputState = ctx.input_state orelse return false;
    return runtime.input.isKeyPressedRepeat(s, key.toRaylib());
}

// ============================================================================
// P3: Long-press → right-click synthesis
// ============================================================================
// Touch-input UX: a long-press should fire the same
// `mouse_right_clicked` event that desktop right-click fires, so
// existing context-menu popups built on `beginPopupContextItem`
// (which reads `snapshot.mouse_right_clicked`) work on phone
// without a parallel "long-press menu" code path.
//

// Detection rules:
//   1. Exactly ONE touch active (multi-touch is a different gesture
//      — pinch/pan — and never triggers synthesis).
//   2. `snapshot.mouse_left_down` (touchstart already bridges to
//      mouse-button-down on the JS side, so this is always true
//      under a single-finger press; the check guards against the
//      "mouse left button released but touch state still present"
//      race window).
//   3. Position drift from the touchdown point < touch_long_press_drift_px.
//      Cancels the synthesis if the user drags.
//   4. Elapsed time since touchdown >= touch_long_press_threshold_ms.
//

// On the frame the threshold crosses:
//   - `snapshot.mouse_right_clicked` is OR'd to true.
//   - `snapshot.mouse_left_down` is cleared so widgets don't
//     interpret the same gesture as both a click and a right-click.
//   - `snapshot.mouse_left_clicked` is cleared (defensive: should
//     already be false this frame because the press was on a prior
//     frame, but covers the corner case where the test harness
//     fires touchdown + ticks both within frame N).
//   - Tracker enters "fired" state and won't re-trigger until the
//     touch lifts.
//

// Desktop mouse path: NEVER triggers, because the helper only acts
// when `touch_count == 1`. A desktop user holding the mouse for
// 500ms keeps `touch_count == 0`.
//

// State storage on UiContext (one tracker; multi-touch beyond 1
// finger resets it).

/// Time the user must hold a single touch (without moving more
/// than `touch_long_press_drift_px`) before the synthesis fires.
pub const touch_long_press_threshold_ms: f32 = 500;

/// Position drift in pixels from the touchdown anchor that cancels
/// the long-press. 6px matches typical touch-jitter on phones
/// without requiring the user to hold dead-still.
pub const touch_long_press_drift_px: f32 = 6;

pub const LongPressTracker = struct {
    /// Browser-assigned id of the tracked touch (-1 = not tracking).
    /// Single-touch only; multi-touch resets to -1.
    touch_id: i32 = -1,
    /// Anchor position at touchdown. Drift is measured against this.
    start_pos: Vec2 = .{ 0, 0 },
    /// Accumulated time since touchdown (ms). Resets to 0 on
    /// touchdown. Set to `THRESHOLD + 1` on fire so subsequent
    /// frames in the same press don't re-fire. Set to a negative
    /// sentinel on drift-cancel so the press is locked out.
    elapsed_ms: f32 = 0,
    /// One-frame flag: true on the frame the synthesis fired this
    /// press cycle. Read-only for callers; reset by the next
    /// `applyLongPressSynthesis` call. Useful for debug logging.
    just_fired_this_frame: bool = false,
};

/// Pure synthesis helper. Reads touch state from `input_state`,
/// mutates `snapshot` to inject the right-click on the frame the
/// long-press threshold crosses, and updates `ctx.long_press`.
///
/// Factored out of `beginFrame` so tests can drive it directly with
/// a synthetic `InputState` + `InputSnapshot` pair, without needing
/// a `Frame` / `GlState` harness.
pub fn applyLongPressSynthesis(
    ctx: *UiContext,
    snapshot: *InputSnapshot,
    input_state: *const runtime.input.InputState,
) void {
    // Reset the per-frame "fired" flag so callers that read it
    // always see the latest result.
    ctx.long_press.just_fired_this_frame = false;

    const touch_count: i32 = runtime.input.getTouchPointCount(input_state);
    if (touch_count != 1) {
        // 0 or 2+ touches — reset tracker.
        ctx.long_press.touch_id = -1;
        ctx.long_press.elapsed_ms = 0;
        return;
    }

    const tid: i32 = runtime.input.getTouchPointId(input_state, 0);
    const tpos: Vec2 = runtime.input.getTouchPosition(input_state, 0);

    if (ctx.long_press.touch_id != tid) {
        // New touch — start tracking.
        ctx.long_press.touch_id = tid;
        ctx.long_press.start_pos = tpos;
        ctx.long_press.elapsed_ms = 0;
        return;
    }

    // Same touch as last frame. Already fired or cancelled?
    if (ctx.long_press.elapsed_ms < 0 or
        ctx.long_press.elapsed_ms > touch_long_press_threshold_ms)
    {
        return;
    }

    // Check drift. If we moved too far from the anchor, cancel
    // this press cycle (will be re-armed on next touchdown).
    const dx: f32 = tpos[0] - ctx.long_press.start_pos[0];
    const dy: f32 = tpos[1] - ctx.long_press.start_pos[1];
    const drift_sq: f32 = dx * dx + dy * dy;
    if (drift_sq > touch_long_press_drift_px * touch_long_press_drift_px) {
        ctx.long_press.elapsed_ms = -1; // cancel
        return;
    }

    // Mouse must still be down — guards the touchend race window
    // where the OS released the mouse button but the touch slot
    // hasn't been compacted yet.
    if (!snapshot.mouse_left_down) {
        ctx.long_press.elapsed_ms = -1;
        return;
    }

    // Accumulate.
    ctx.long_press.elapsed_ms += snapshot.delta_time * 1000.0;
    if (ctx.long_press.elapsed_ms < touch_long_press_threshold_ms) {
        return;
    }

    // Threshold crossed THIS frame. Inject the right-click and
    // mark fired. OR-semantics: if the real mouse-right button
    // ALSO clicked this frame, the merge is idempotent.
    snapshot.mouse_right_clicked = true;
    snapshot.mouse_left_down = false;
    snapshot.mouse_left_clicked = false;
    ctx.long_press.elapsed_ms = touch_long_press_threshold_ms + 1;
    ctx.long_press.just_fired_this_frame = true;
}

/// Pending tooltip submission - collected during the frame, rendered
/// at endFrame on top of all other content. Owns its text in a
/// fixed-size buffer (no heap allocation per tooltip).
pub const TooltipData = struct {
    pos: Vec2,
    text_buf: [256]u8 = undefined,
    text_len: usize = 0,

    pub fn text(self: *const TooltipData) []const u8 {
        return self.text_buf[0..self.text_len];
    }
};

/// What the UI is currently rendering to. The default surface is
/// the main canvas (screen-space pixels). `pushRenderTexture` adds
/// a surface for the duration of an RT scope. Used to (a) compute
/// ortho projection at the right size, (b) restore viewport when
/// popping back to the outer surface, (c) inform `image()` to
/// auto-flip V when the source is an RT.
pub const Surface = struct {
    /// Pixel width of the render target.
    width: u32,
    /// Pixel height of the render target.
    height: u32,
    /// True for render targets - origin bottom-left, V flipped at
    /// sample time. The default canvas surface is `false`.
    y_flipped: bool = false,
};

/// Per-active-input edit state for `inputText`. Lives on UiContext
/// - only the currently-focused input cares about it, so a single
/// instance is enough. Reset on focus change.
pub const InputTextState = struct {
    /// Byte offset of the cursor within the buffer's current
    /// content. In `[0, len]` inclusive (cursor sits BETWEEN bytes;
    /// at `len` means "after the last char").
    cursor_pos: usize = 0,
    /// Accumulated time since the input gained focus. Used to
    /// blink the cursor. Reset on focus.
    blink_t: f32 = 0,
};

/// Persistent per-window state. Created on first `window(title,
/// ...)` call by hashing the title. Position, size, scroll, etc.
/// survive across frames. The window store is App-owned.
// ============================================================================
// Draw command list - deferred rendering for proper z-order
// ============================================================================
// Widgets append commands to a `DrawList` instead of emitting to rlgl
// immediately. At endFrame, draw lists are replayed in z-order:
// window draw lists first, foreground draw list (popups, tooltips)
// last. This gives us:
//   - **Proper z-ordering** - popups overlay rather than reflow,
//     tooltips always on top, multiple popups stack correctly
//   - **Per-widget clip rects** - push_clip / pop_clip commands
//     translate to rlgl scissor changes at replay time
//   - **Order-independent submission** - combo can call setTooltip
//     without worrying about who renders first
//   - **Snapshot-testable** - hash the draw list, compare frames,
//     no GL context needed
// Why a command list instead of ImGui's vertex-buffer approach:
// rlgl already batches GL emissions internally (rlBegin/rlVertex
// append to its vertex buffer; flush on state change). Recording
// commands and replaying through `shapes.drawRectangleRec` /
// `text.drawWithFont` lets rlgl do its own batching downstream. We avoid
// duplicating rlgl's machinery and keep one source of GL-call truth.
// Tradeoff: no per-cmd shader override (rlgl doesn't expose mid-batch
// program swap). For zimr's use case - UI overlaid on raylib scenes
// we never wanted that anyway.
// **Eager mode** - when `pushRenderTexture` is in scope, draw helpers
// emit straight to rlgl instead of recording. This keeps the
// recursive_hud demo's "push RT, draw stuff, pop RT" semantics.
// `eager_mode: bool` on UiContext is the switch.

/// One drawing operation, recorded for later replay. Tagged union so
/// switch dispatch at replay is exhaustive.
pub const DrawCmd = union(enum) {
    rect_filled: struct { rect: Rectangle, col: ColorU32 },
    rect_outline: struct { rect: Rectangle, col: ColorU32 },
    textured_quad: struct {
        rect: Rectangle,
        tex_id: u32,
        uv0: Vec2,
        uv1: Vec2,
        col: ColorU32,
    },
    /// A Sprite drawn at replay: residency (upload + cache) resolves against the
    /// replay backend's `image`, so record time needs no renderer. The Sprite is
    /// copied by value (id + Image view); its pixels must outlive the frame's
    /// replay — true for the per-frame DrawList lifecycle.
    sprite_quad: struct {
        dst: Rectangle,
        sprite: Sprite,
        opts: draw2d.ImageOpts,
    },
    text: struct {
        /// Bytes live in the DrawList's `arena` so the slice
        /// outlives the user's `update()` return. Invalidated on
        /// the next `clear()`.
        font: ?*const Font,
        s: []const u8,
        pos: Vec2,
        size: f32,
        spacing: f32,
        line_spacing: i32,
        col: ColorU32,
    },
    /// Push a scissor rect onto the rlgl scissor stack at replay.
    push_clip: struct { rect: Rectangle },
    /// Pop the topmost scissor rect at replay.
    pop_clip: void,

    // ---- primitives
    // The 13 primitives mirror imgui's `ImDrawList::Add*` surface. Each
    // replay arm calls into `shapes2d.*` - no new rasterizer code,
    // just a record/replay wrapper over raylib's existing shape API.

    line: struct {
        a: Vec2,
        b: Vec2,
        col: ColorU32,
        thickness: f32,
    },
    /// Polyline / line strip. When `closed`, the renderer adds a
    /// final segment from `points[count-1]` back to `points[0]`.
    /// Points live in the DrawList's arena.
    polyline: struct {
        points: []const Vec2,
        col: ColorU32,
        thickness: f32,
        closed: bool,
    },
    triangle: struct {
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: ColorU32,
        thickness: f32,
    },
    triangle_filled: struct {
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: ColorU32,
    },
    quad_filled: struct {
        a: Vec2,
        b: Vec2,
        c: Vec2,
        d: Vec2,
        col: ColorU32,
    },
    /// Circle outline. `n_segments = 0` means auto-compute from
    /// radius via `autoCircleSegments`.
    circle: struct {
        center: Vec2,
        radius: f32,
        col: ColorU32,
        thickness: f32,
        n_segments: u32,
    },
    /// Filled circle. Same `n_segments = 0 → auto` convention.
    circle_filled: struct {
        center: Vec2,
        radius: f32,
        col: ColorU32,
        n_segments: u32,
    },
    /// Regular polygon outline (triangle / square / hex / pentagon…).
    /// `sides` is explicit - stylistic markers want exact shapes.
    ngon: struct {
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: ColorU32,
        thickness: f32,
    },
    ngon_filled: struct {
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: ColorU32,
    },
    bezier_cubic: struct {
        p1: Vec2,
        p2: Vec2,
        p3: Vec2,
        p4: Vec2,
        col: ColorU32,
        thickness: f32,
    },
    ellipse: struct {
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: ColorU32,
        thickness: f32,
    },
    ellipse_filled: struct {
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: ColorU32,
    },
    /// Four-color gradient rectangle. `c_tl`/`c_tr`/`c_br`/`c_bl` are
    /// the corner colors; raylib interpolates linearly between them.
    rect_filled_multi_color: struct {
        rect: Rectangle,
        c_tl: ColorU32,
        c_tr: ColorU32,
        c_br: ColorU32,
        c_bl: ColorU32,
    },
};

/// One entry on a CanvasCtx's transform stack: 2D translate +
/// per-axis scale. A point `P` in child space maps to
/// `P * scale + translate` in the parent space. Rotation is
/// intentionally omitted — pan+zoom (the dominant canvas use
/// case) needs only translate+scale, and the composition math
/// stays a 5-multiply / 4-add affair instead of a full 2×2
/// matrix. If rotation ever becomes needed, this struct gains
/// a `rotation: f32` field and `compose` grows ~6 trig ops.
///
/// Module-level (not nested under `Ui`) because UiContext holds
/// the stack and forward-references through `Ui` would create
/// a circular dep.
pub const CanvasTransform = struct {
    translate: Vec2 = .{ 0, 0 },
    scale: Vec2 = .{ 1, 1 },

    /// Compose `child` ONTO `parent`. Result is the transform
    /// that applies `child` first, then `parent`. Used at push
    /// time so the stack always stores cumulative transforms —
    /// `top()` returns "everything stacked so far" directly, no
    /// walk needed.
    pub fn compose(parent: CanvasTransform, child: CanvasTransform) CanvasTransform {
        return .{
            .translate = .{
                child.translate[0] * parent.scale[0] + parent.translate[0],
                child.translate[1] * parent.scale[1] + parent.translate[1],
            },
            .scale = .{
                child.scale[0] * parent.scale[0],
                child.scale[1] * parent.scale[1],
            },
        };
    }

    /// Apply this transform to a point in child space:
    /// `out = p * scale + translate`.
    pub fn applyLocal(self: CanvasTransform, p: Vec2) Vec2 {
        return .{
            p[0] * self.scale[0] + self.translate[0],
            p[1] * self.scale[1] + self.translate[1],
        };
    }

    /// Invert the transform. Inverse exists iff scale[0] and
    /// scale[1] are both non-zero. Used by `localMouse` to pull
    /// screen-space mouse coords back into the canvas's authoring
    /// space. Caller responsibility: don't push a scale=0
    /// transform if you plan to call localMouse.
    pub fn inverse(self: CanvasTransform) CanvasTransform {
        const inv_sx: f32 = if (self.scale[0] != 0) 1.0 / self.scale[0] else 0;
        const inv_sy: f32 = if (self.scale[1] != 0) 1.0 / self.scale[1] else 0;
        return .{
            .translate = .{
                -self.translate[0] * inv_sx,
                -self.translate[1] * inv_sy,
            },
            .scale = .{ inv_sx, inv_sy },
        };
    }
};

/// Unpack a packed RGBA `ColorU32` into a `Color`. Format:
/// little-endian RGBA (matching `colorToU32` below).
/// Emit a textured quad to the renderer (gl: anytype). Used at draw list replay.
/// Two triangles, single rlBegin/rlEnd, sets+resets texture.
fn renderTexturedQuad(
    gl: anytype,
    r: Rectangle,
    tex_id: u32,
    uv0: Vec2,
    uv1: Vec2,
    col: ColorU32,
) void {
    const r8: u8 = @truncate((col >> 0) & 0xFF);
    const g8: u8 = @truncate((col >> 8) & 0xFF);
    const b8: u8 = @truncate((col >> 16) & 0xFF);
    const a8: u8 = @truncate((col >> 24) & 0xFF);

    gl.setTexture(tex_id);
    gl.begin(.triangles);
    gl.color4ub(r8, g8, b8, a8);

    const x0: f32 = r.x;
    const y0: f32 = r.y;
    const x1: f32 = r.x + r.width;
    const y1: f32 = r.y + r.height;

    gl.texCoord2f(uv0[0], uv0[1]);
    gl.vertex2f(x0, y0);
    gl.texCoord2f(uv0[0], uv1[1]);
    gl.vertex2f(x0, y1);
    gl.texCoord2f(uv1[0], uv1[1]);
    gl.vertex2f(x1, y1);

    gl.texCoord2f(uv0[0], uv0[1]);
    gl.vertex2f(x0, y0);
    gl.texCoord2f(uv1[0], uv1[1]);
    gl.vertex2f(x1, y1);
    gl.texCoord2f(uv1[0], uv0[1]);
    gl.vertex2f(x1, y0);

    gl.end();
    gl.setTexture(0);
}

pub const DrawList = struct {
    /// Recorded commands. Lives in the UiContext's frame arena
    /// (allocator is passed per `add*` call). The list slice
    /// `cmds.items` is invalidated whenever the arena is reset
    /// at endFrame - that's why `clear()` zeros the list back to
    /// `.empty` rather than calling `clearRetainingCapacity`
    /// (which would keep a stale items.ptr).
    cmds: ArrayList(DrawCmd) = .empty,

    /// Deinit the command list. Pass the same allocator used by the
    /// `add*` calls (in production, that's the UiContext's frame
    /// arena; in tests, it's typically `std.testing.allocator`).
    /// Safe to call with the arena allocator even though the arena
    /// will be deinit'd separately - `cmds.deinit` on arena memory
    /// is a no-op `free` plus a slice zero, which is harmless.
    pub fn deinit(self: *DrawList, alloc: Allocator) void {
        self.cmds.deinit(alloc);
    }

    /// Reset the command list for a new frame's submission. The
    /// arena may have been reset since the last submission, in
    /// which case `cmds.items.ptr` points at memory that's been
    /// reused - so we zero the list rather than calling
    /// `clearRetainingCapacity`.
    pub fn clear(self: *DrawList) void {
        self.cmds = .empty;
    }

    pub fn isEmpty(self: *const DrawList) bool {
        return self.cmds.items.len == 0;
    }

    /// Append a filled rectangle. OOM is silently dropped - UI
    /// degrades gracefully under memory pressure (a missing rect is
    /// ugly, not a crash).
    pub fn addRectFilled(
        self: *DrawList,
        gpa: Allocator,
        rect: Rectangle,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .rect_filled = .{ .rect = rect, .col = col } }) catch {};
    }

    pub fn addRectOutline(
        self: *DrawList,
        gpa: Allocator,
        rect: Rectangle,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .rect_outline = .{ .rect = rect, .col = col } }) catch {};
    }

    pub fn addTexturedQuad(
        self: *DrawList,
        gpa: Allocator,
        rect: Rectangle,
        tex_id: u32,
        uv0: Vec2,
        uv1: Vec2,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .textured_quad = .{
            .rect = rect,
            .tex_id = tex_id,
            .uv0 = uv0,
            .uv1 = uv1,
            .col = col,
        } }) catch {};
    }

    pub fn addSpriteQuad(
        self: *DrawList,
        gpa: Allocator,
        dst: Rectangle,
        sprite: Sprite,
        opts: draw2d.ImageOpts,
    ) void {
        self.cmds.append(gpa, .{ .sprite_quad = .{
            .dst = dst,
            .sprite = sprite,
            .opts = opts,
        } }) catch {};
    }

    /// Record a text command. String bytes are duplicated into
    /// `alloc` (which must be the UiContext's frame arena) so they
    /// outlive the caller's stack frame. `line_spacing` is captured
    /// at record time so the replay path doesn't need a separate
    /// parameter - multiple records in the same DrawList can have
    /// different line-spacing values if the user mutated
    /// `ctx.style.line_spacing` between calls.
    pub fn addText(
        self: *DrawList,
        alloc: Allocator,
        font: ?*const Font,
        s: []const u8,
        pos: Vec2,
        size: f32,
        spacing: f32,
        line_spacing: i32,
        col: ColorU32,
    ) void {
        if (s.len == 0) {
            return;
        }
        const owned: []const u8 = alloc.dupe(u8, s) catch return;
        self.cmds.append(alloc, .{ .text = .{
            .font = font,
            .s = owned,
            .pos = pos,
            .size = size,
            .spacing = spacing,
            .line_spacing = line_spacing,
            .col = col,
        } }) catch {};
    }

    // ---- primitives
    pub fn addLine(
        self: *DrawList,
        gpa: Allocator,
        a: Vec2,
        b: Vec2,
        col: ColorU32,
        thickness: f32,
    ) void {
        self.cmds.append(gpa, .{ .line = .{
            .a = a,
            .b = b,
            .col = col,
            .thickness = thickness,
        } }) catch {};
    }

    /// Record a polyline (line strip). Points are duplicated into
    /// `alloc` (the UiContext's frame arena) so they survive past
    /// the caller's frame. `closed = true` adds a final segment
    /// from `points[count-1]` back to `points[0]`. OOM drops the
    /// command silently.
    pub fn addPolyline(
        self: *DrawList,
        alloc: Allocator,
        points: []const Vec2,
        col: ColorU32,
        thickness: f32,
        closed: bool,
    ) void {
        if (points.len < 2) {
            return;
        }
        const owned: []const Vec2 = alloc.dupe(Vec2, points) catch return;
        self.cmds.append(alloc, .{ .polyline = .{
            .points = owned,
            .col = col,
            .thickness = thickness,
            .closed = closed,
        } }) catch {};
    }

    // Arcs and polygons are emitted at submission time as
    // `polyline` (stroked) / `triangle_filled` (filled) commands —
    // no dedicated renderer-side DrawCmd variants. Cost: each
    // filled arc costs `segments` separate triangle_filled cmds
    // vs a single command record with center+radius+a0+a1. Fine
    // for occasional knobs/gauges; if arc spam ever shows up in
    // a profile, add a dedicated `arc` / `arc_filled` variant
    // and route through `shapes2d.drawRingFilled`.
    //

    // Angle convention: radians, 0 = +X axis, CCW positive. Same
    // as math.zig. Imgui uses a similar convention internally;
    // public API exposed in radians not degrees to avoid the
    // multiply at every call site.

    /// Stroked arc from `a0` to `a1` radians on a circle of
    /// `radius` centered at `center`. When `a1 < a0`, sweep goes
    /// clockwise (matches naive interpretation). `segments = 0`
    /// auto-derives a count from the arc length and radius (matches
    /// the existing `n_segments = 0` convention on circles).
    pub fn addArc(
        self: *DrawList,
        alloc: Allocator,
        center: Vec2,
        radius: f32,
        a0: f32,
        a1: f32,
        col: ColorU32,
        thickness: f32,
        segments: u32,
    ) void {
        if (radius <= 0 or thickness <= 0) {
            return;
        }
        const seg_count: u32 = if (segments == 0)
            autoArcSegments(radius, a1 - a0)
        else
            segments;
        if (seg_count < 1) {
            return;
        }
        const n_points: usize = @intCast(seg_count + 1);
        const points: []Vec2 = alloc.alloc(Vec2, n_points) catch return;
        defer alloc.free(points);
        const step: f32 = (a1 - a0) / float(seg_count);
        var i: usize = 0;
        while (i < n_points) : (i += 1) {
            const a: f32 = a0 + step * float(i);
            points[i] = .{
                center[0] + radius * @cos(a),
                center[1] + radius * @sin(a),
            };
        }
        // Polyline is the natural fit — `addPolyline` handles the
        // dupe-into-arena step, so points[] is safe to release on
        // function exit.
        self.addPolyline(alloc, points, col, thickness, false);
    }

    /// Filled arc (pie slice) — fan-triangulates from `center` to
    /// `segments + 1` points on the arc. When `a1 - a0 == 2π`,
    /// degenerates to a filled disk (equivalent to
    /// `addCircleFilled` minus the auto-segment-count convenience).
    /// `segments = 0` auto-derives.
    pub fn addArcFilled(
        self: *DrawList,
        alloc: Allocator,
        center: Vec2,
        radius: f32,
        a0: f32,
        a1: f32,
        col: ColorU32,
        segments: u32,
    ) void {
        if (radius <= 0) {
            return;
        }
        const seg_count: u32 = if (segments == 0)
            autoArcSegments(radius, a1 - a0)
        else
            segments;
        if (seg_count < 1) {
            return;
        }
        const step: f32 = (a1 - a0) / float(seg_count);
        // Triangle-fan: each adjacent pair of perimeter points
        // forms a triangle with the center. No allocation needed
        // — point pairs are computed on the fly.
        var i: u32 = 0;
        var prev: Vec2 = .{
            center[0] + radius * @cos(a0),
            center[1] + radius * @sin(a0),
        };
        while (i < seg_count) : (i += 1) {
            const a_next: f32 = a0 + step * float(i + 1);
            const next: Vec2 = .{
                center[0] + radius * @cos(a_next),
                center[1] + radius * @sin(a_next),
            };
            self.addTriangleFilled(alloc, center, prev, next, col);
            prev = next;
        }
    }

    /// Filled polygon over the given points, fan-triangulated from
    /// `points[0]`. Assumes the polygon is convex; concave polygons
    /// will render with overlapping triangles (caller's problem).
    /// Returns silently with < 3 points.
    pub fn addPolygon(
        self: *DrawList,
        alloc: Allocator,
        points: []const Vec2,
        col: ColorU32,
    ) void {
        if (points.len < 3) {
            return;
        }
        // Fan from points[0]. Each triangle: (0, i, i+1).
        var i: usize = 1;
        while (i + 1 < points.len) : (i += 1) {
            self.addTriangleFilled(alloc, points[0], points[i], points[i + 1], col);
        }
    }

    pub fn addTriangle(
        self: *DrawList,
        gpa: Allocator,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: ColorU32,
        thickness: f32,
    ) void {
        self.cmds.append(gpa, .{ .triangle = .{
            .a = a,
            .b = b,
            .c = c,
            .col = col,
            .thickness = thickness,
        } }) catch {};
    }

    pub fn addTriangleFilled(
        self: *DrawList,
        gpa: Allocator,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .triangle_filled = .{
            .a = a,
            .b = b,
            .c = c,
            .col = col,
        } }) catch {};
    }

    pub fn addQuadFilled(
        self: *DrawList,
        gpa: Allocator,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        d: Vec2,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .quad_filled = .{
            .a = a,
            .b = b,
            .c = c,
            .d = d,
            .col = col,
        } }) catch {};
    }

    /// Circle outline. Pass `n_segments = 0` to let the replay
    /// path pick a segment count from `radius` via
    /// `autoCircleSegments`.
    pub fn addCircle(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius: f32,
        col: ColorU32,
        thickness: f32,
        n_segments: u32,
    ) void {
        self.cmds.append(gpa, .{ .circle = .{
            .center = center,
            .radius = radius,
            .col = col,
            .thickness = thickness,
            .n_segments = n_segments,
        } }) catch {};
    }

    pub fn addCircleFilled(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius: f32,
        col: ColorU32,
        n_segments: u32,
    ) void {
        self.cmds.append(gpa, .{ .circle_filled = .{
            .center = center,
            .radius = radius,
            .col = col,
            .n_segments = n_segments,
        } }) catch {};
    }

    /// Regular polygon outline. `sides` is explicit (no auto) since
    /// stylistic markers (hex, triangle) want exact shape counts.
    pub fn addNgon(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: ColorU32,
        thickness: f32,
    ) void {
        self.cmds.append(gpa, .{ .ngon = .{
            .center = center,
            .radius = radius,
            .sides = sides,
            .rotation_rad = rotation_rad,
            .col = col,
            .thickness = thickness,
        } }) catch {};
    }

    pub fn addNgonFilled(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .ngon_filled = .{
            .center = center,
            .radius = radius,
            .sides = sides,
            .rotation_rad = rotation_rad,
            .col = col,
        } }) catch {};
    }

    /// Cubic Bezier curve. Drawn at replay via raylib's
    /// `drawLineBezierCubic`.
    pub fn addBezierCubic(
        self: *DrawList,
        gpa: Allocator,
        p1: Vec2,
        p2: Vec2,
        p3: Vec2,
        p4: Vec2,
        col: ColorU32,
        thickness: f32,
    ) void {
        self.cmds.append(gpa, .{ .bezier_cubic = .{
            .p1 = p1,
            .p2 = p2,
            .p3 = p3,
            .p4 = p4,
            .col = col,
            .thickness = thickness,
        } }) catch {};
    }

    pub fn addEllipse(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: ColorU32,
        thickness: f32,
    ) void {
        self.cmds.append(gpa, .{ .ellipse = .{
            .center = center,
            .radius_h = radius_h,
            .radius_v = radius_v,
            .col = col,
            .thickness = thickness,
        } }) catch {};
    }

    pub fn addEllipseFilled(
        self: *DrawList,
        gpa: Allocator,
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .ellipse_filled = .{
            .center = center,
            .radius_h = radius_h,
            .radius_v = radius_v,
            .col = col,
        } }) catch {};
    }

    /// Four-color gradient rectangle (TL, TR, BR, BL colors).
    /// Drawn at replay via raylib's `drawRectangleGradientEx`.
    pub fn addRectFilledMultiColor(
        self: *DrawList,
        gpa: Allocator,
        rect: Rectangle,
        c_tl: ColorU32,
        c_tr: ColorU32,
        c_br: ColorU32,
        c_bl: ColorU32,
    ) void {
        self.cmds.append(gpa, .{ .rect_filled_multi_color = .{
            .rect = rect,
            .c_tl = c_tl,
            .c_tr = c_tr,
            .c_br = c_br,
            .c_bl = c_bl,
        } }) catch {};
    }

    pub fn pushClipRect(
        self: *DrawList,
        gpa: Allocator,
        rect: Rectangle,
    ) void {
        // Catch degenerate clip rects at submission time, not at the
        // GL boundary.  A negative-height clip eventually trips
        // WebGL's GL_INVALID_VALUE inside `rlScissor` — that
        // downstream assert exists but its stack trace points at the
        // batch replay, not the widget that pushed the bad rect.
        // Asserting here surfaces the actual caller in the wasm
        // stack and gives us the rect's components for diagnosis.
        // Width-0 / negative-height most often means a window's
        // outer size came out smaller than `title_bar_height`,
        // making `inner = outer - title_bar` go negative.
        assertf(
            rect.width >= 0 and rect.height >= 0,
            @src(),
            "DrawList.pushClipRect: degenerate rect ({d:.0},{d:.0} {d:.0}×{d:.0}) — " ++
                "width or height is negative.  Common cause: window outer size " ++
                "smaller than title_bar_height.  Clamp the source computation " ++
                "to >= 0 (e.g. `@max(0, sh - chrome)`).",
            .{ rect.x, rect.y, rect.width, rect.height },
        );
        self.cmds.append(gpa, .{ .push_clip = .{ .rect = rect } }) catch {};
    }

    pub fn popClipRect(self: *DrawList, gpa: Allocator) void {
        self.cmds.append(gpa, .pop_clip) catch {};
    }

    /// Pick a segment count for a circle of `radius` so the
    /// polygonal approximation has at most ~0.30 pixels of error
    /// against a true circle. Used when `n_segments = 0` is
    /// passed to `addCircle` / `addCircleFilled`. Port of
    /// imgui's `_CalcCircleAutoSegmentCount`.
    /// The math: an N-segment polygon approximates a circle of
    /// radius r with max edge-to-arc distance `r * (1 - cos(π/N))`.
    /// Solve for N at the target error: N = π / acos(1 - err/r).
    /// Clamp to [12, 512] to avoid degenerate cases at tiny/huge
    /// radii.
    fn autoCircleSegments(radius: f32) u32 {
        const min_segments: u32 = 12;
        const max_segments: u32 = 512;
        const max_error: f32 = 0.30;
        if (radius <= max_error) {
            return min_segments;
        }
        const ratio: f32 = 1.0 - max_error / radius;
        const denom: f32 = acos(ratio);
        // Huge radii drive acos(ratio) toward 0, which would send
        // n_segments to infinity. Cap explicitly.
        if (denom <= 0 or !isFinite(denom)) {
            return max_segments;
        }
        const n: f32 = pi / denom;
        if (!isFinite(n) or n >= float(max_segments)) {
            return max_segments;
        }
        const rounded: u32 = @ceil(n);
        return clamp(rounded, min_segments, max_segments);
    }

    /// Auto-derived segment count for an arc spanning `arc_angle`
    /// radians on a circle of `radius`. Uses the same error
    /// budget as `autoCircleSegments` (0.30 px max edge-to-arc
    /// distance) but scales the count to the arc fraction:
    /// `segments = autoCircleSegments(r) * (|arc_angle| / 2π)`.
    /// Clamped to [1, 512].
    fn autoArcSegments(radius: f32, arc_angle: f32) u32 {
        const full: u32 = autoCircleSegments(radius);
        const frac: f32 = @abs(arc_angle) / (2.0 * pi);
        const scaled: f32 = float(full) * frac;
        const rounded: u32 = @ceil(scaled);
        return @max(rounded, 1);
    }

    /// Replay all commands, emitting to rlgl through the existing
    /// `shapes.drawRectangleRec` / `text.draw` paths. rlgl batches
    /// the resulting vertex appends downstream.
    /// At replay time, the GL state (projection, viewport, framebuffer)
    /// must already be set up by the caller - this function just
    /// emits draw calls.
    /// `gl` is mutated for every shape/text/scissor command emitted.
    /// `window` is read-only - only the push_clip path needs canvas
    /// dimensions for the scissor y-flip. Each `.text` cmd carries
    /// its own `line_spacing` (captured at `addText` time).
    /// Replay all recorded commands. This is the deferred-render
    /// path: each `addRect`/`addText`/etc captured during widget
    /// submission gets translated into the corresponding `drawing`
    /// fn call here, in submission order.
    /// Reads `self.cmds` (the recorded command list); cmd payloads
    /// for variable-length data (`.text.s`, `.polyline.points`)
    /// carry slices into the DrawList's arena, used directly here.
    /// `shapes_state.*` provides the texture for every rect/quad;
    /// `font_cache.font` is the default font fallback for `.text`
    /// commands without a captured font.
    /// `gl` is mutated for every shape/text/scissor command emitted.
    /// `window` is read-only - only the push_clip path needs canvas
    /// dimensions for the scissor y-flip. Each `.text` cmd carries
    /// its own `line_spacing` (captured at `addText` time).
    /// `gl: anytype` - passes through to shapes2d / text2d
    /// which call method-style (`gl.begin(.quads)` etc). Both
    /// `*Gl` and `*raster.Context` satisfy the trait, so the
    /// same DrawList drives either backend.
    pub fn render(
        self: *const DrawList,
        gl: anytype,
        window: *const runtime.core.WindowState,
        shapes_state: *const shapes2d.ShapesTextureState,
        font_cache: *const text2d.FontCache,
    ) void {
        // Clip-rect stack (CSS-px). Each push intersects with the parent so a
        // nested clip can't exceed it; each pop restores the parent rather than
        // disabling the scissor outright (the old counter did the latter, which
        // both let oversized children spill past their window and left later
        // draws unclipped).
        var clip_stack: [32]Rectangle = undefined;
        var clip_top: usize = 0;
        for (self.cmds.items) |cmd| switch (cmd) {
            .rect_filled => |c| shapes2d.drawRectangleRec(gl, shapes_state, c.rect, Color.fromWire(c.col)),
            .rect_outline => |c| {
                // Four 1px filled strips. Same path as the old eager
                // drawRect - kept consistent so visuals match.
                const col: Color = Color.fromWire(c.col);
                const r: Rectangle = c.rect;
                shapes2d.drawRectangleRec(
                    gl,
                    shapes_state,
                    .{ .x = r.x, .y = r.y, .width = r.width, .height = 1 },
                    col,
                );
                shapes2d.drawRectangleRec(
                    gl,
                    shapes_state,
                    .{ .x = r.x, .y = r.y + r.height - 1, .width = r.width, .height = 1 },
                    col,
                );
                shapes2d.drawRectangleRec(
                    gl,
                    shapes_state,
                    .{ .x = r.x, .y = r.y, .width = 1, .height = r.height },
                    col,
                );
                shapes2d.drawRectangleRec(
                    gl,
                    shapes_state,
                    .{ .x = r.x + r.width - 1, .y = r.y, .width = 1, .height = r.height },
                    col,
                );
            },
            .textured_quad => |c| renderTexturedQuad(gl, c.rect, c.tex_id, c.uv0, c.uv1, c.col),
            .sprite_quad => |c| {
                // Residency resolves at replay via the backend's `image`. Backends
                // without a high-level `image` (the bare SW raster context, test
                // mocks) skip sprite draws rather than failing to compile — the UI
                // DrawList is replayed on WgpuGl in practice.
                const GlT: type = @TypeOf(gl);
                const Target: type = if (@typeInfo(GlT) == .pointer) @typeInfo(GlT).pointer.child else GlT;
                if (comptime @hasDecl(Target, "image")) {
                    gl.image(c.dst, c.sprite, c.opts);
                }
            },
            .text => |c| {
                if (c.font) |f| {
                    text2d.drawWithFont(
                        gl,
                        c.line_spacing,
                        f.*,
                        c.s,
                        c.pos,
                        c.size,
                        c.spacing,
                        Color.fromWire(c.col),
                    );
                } else {
                    text2d.draw(
                        gl,
                        font_cache,
                        c.line_spacing,
                        c.s,
                        @trunc(c.pos[0]),
                        @trunc(c.pos[1]),
                        @trunc(c.size),
                        Color.fromWire(c.col),
                    );
                }
            },
            .push_clip => |c| {
                const r: Rectangle = if (clip_top == 0)
                    c.rect
                else
                    intersectClip(clip_stack[clip_top - 1], c.rect);
                if (clip_top < clip_stack.len) {
                    clip_stack[clip_top] = r;
                    clip_top += 1;
                }
                shapes2d.beginScissorMode(
                    gl,
                    window,
                    @trunc(r.x),
                    @trunc(r.y),
                    @trunc(r.width),
                    @trunc(r.height),
                );
            },
            .pop_clip => {
                if (clip_top > 0) {
                    clip_top -= 1;
                }
                if (clip_top > 0) {
                    const r: Rectangle = clip_stack[clip_top - 1];
                    shapes2d.beginScissorMode(
                        gl,
                        window,
                        @trunc(r.x),
                        @trunc(r.y),
                        @trunc(r.width),
                        @trunc(r.height),
                    );
                } else {
                    shapes2d.endScissorMode(gl);
                }
            },

            // ---- primitive replay
            .line => |c| {
                shapes2d.drawLineThick(gl, shapes_state, c.a, c.b, c.thickness, Color.fromWire(c.col));
            },
            .polyline => |c| {
                const col: Color = Color.fromWire(c.col);
                // Render as N-1 (or N if closed) line segments. raylib's
                // drawLineStrip doesn't respect thickness, so we loop
                // drawLineThick ourselves.
                var i: usize = 0;
                while (i + 1 < c.points.len) : (i += 1) {
                    shapes2d.drawLineThick(gl, shapes_state, c.points[i], c.points[i + 1], c.thickness, col);
                }
                if (c.closed and c.points.len >= 2) {
                    shapes2d.drawLineThick(
                        gl,
                        shapes_state,
                        c.points[c.points.len - 1],
                        c.points[0],
                        c.thickness,
                        col,
                    );
                }
            },
            .triangle => |c| {
                // Three thick line segments. raylib's drawTriangleLines is
                // fixed-1px (uses RL_LINES), so we use drawLineThick for
                // each edge to honor thickness.
                const col: Color = Color.fromWire(c.col);
                shapes2d.drawLineThick(gl, shapes_state, c.a, c.b, c.thickness, col);
                shapes2d.drawLineThick(gl, shapes_state, c.b, c.c, c.thickness, col);
                shapes2d.drawLineThick(gl, shapes_state, c.c, c.a, c.thickness, col);
            },
            .triangle_filled => |c| {
                shapes2d.drawTriangle(gl, shapes_state, c.a, c.b, c.c, Color.fromWire(c.col));
            },
            .quad_filled => |c| {
                // Two triangles sharing edge a-c: (a,b,c) and (a,c,d).
                const col: Color = Color.fromWire(c.col);
                shapes2d.drawTriangle(gl, shapes_state, c.a, c.b, c.c, col);
                shapes2d.drawTriangle(gl, shapes_state, c.a, c.c, c.d, col);
            },
            .circle => |c| {
                // Outline circle = ngon outline at high segment count.
                // raylib's drawCircleLinesV is fixed-1px, but
                // drawPolyLinesThick takes both sides + thickness, which
                // is exactly what we need.
                const segs: u32 = if (c.n_segments == 0) autoCircleSegments(c.radius) else c.n_segments;
                shapes2d.drawPolyLinesThick(
                    gl,
                    shapes_state,
                    c.center,
                    @intCast(segs),
                    c.radius,
                    0,
                    c.thickness,
                    Color.fromWire(c.col),
                );
            },
            .circle_filled => |c| {
                // Filled circle = filled ngon at high segment count.
                // drawPoly + auto-segments gives us a smoother fill
                // than raylib's drawCircleV (which uses a fixed
                // 36-segment internal default).
                const segs: u32 = if (c.n_segments == 0) autoCircleSegments(c.radius) else c.n_segments;
                shapes2d.drawPoly(
                    gl,
                    shapes_state,
                    c.center,
                    @intCast(segs),
                    c.radius,
                    0,
                    Color.fromWire(c.col),
                );
            },
            .ngon => |c| {
                shapes2d.drawPolyLinesThick(
                    gl,
                    shapes_state,
                    c.center,
                    @intCast(c.sides),
                    c.radius,
                    c.rotation_rad,
                    c.thickness,
                    Color.fromWire(c.col),
                );
            },
            .ngon_filled => |c| {
                shapes2d.drawPoly(
                    gl,
                    shapes_state,
                    c.center,
                    @intCast(c.sides),
                    c.radius,
                    c.rotation_rad,
                    Color.fromWire(c.col),
                );
            },
            .bezier_cubic => |c| {
                shapes2d.drawSplineSegmentBezierCubic(
                    gl,
                    shapes_state,
                    c.p1,
                    c.p2,
                    c.p3,
                    c.p4,
                    c.thickness,
                    Color.fromWire(c.col),
                );
            },
            .ellipse => |c| {
                // No thick-line variant for ellipses in raylib (1px
                // outline only). For thicker outlines, fall back to
                // a polyline at higher segment count - not done yet.
                shapes2d.drawEllipseLinesV(
                    gl,
                    c.center,
                    c.radius_h,
                    c.radius_v,
                    Color.fromWire(c.col),
                );
                _ = c.thickness;
            },
            .ellipse_filled => |c| {
                shapes2d.drawEllipseV(gl, c.center, c.radius_h, c.radius_v, Color.fromWire(c.col));
            },
            .rect_filled_multi_color => |c| {
                // raylib's signature is (TL, BL, BR, TR) - note the
                // diagonal order vs imgui's clockwise.
                shapes2d.drawRectangleGradientEx(
                    gl,
                    shapes_state,
                    c.rect,
                    Color.fromWire(c.c_tl),
                    Color.fromWire(c.c_bl),
                    Color.fromWire(c.c_br),
                    Color.fromWire(c.c_tr),
                );
            },
        };
        // Defensive: end any scissor left dangling (should never happen
        // with a balanced widget - but if a user opened a scrollable
        // child and forgot to close it, we don't leak GL state).
        if (clip_top > 0) {
            shapes2d.endScissorMode(gl);
        }
    }
};

/// Multi-channel command buffer for rendering a region with a
/// well-defined paint order regardless of submission interleave.
///
/// Background: when rendering a table cell-by-cell, you want to
/// paint the cell background BEFORE the cell content, but both are
/// submitted while iterating columns left-to-right. Without
/// channels, painting the bg in cell N would paint over the content
/// of cell N-1. With a `DrawListSplitter`, the caller sets up two
/// channels (channel 0 = bg, channel 1 = content), submits each
/// cell's bg then content via `setCurrentChannel` + `getCurrentChannel`,
/// and at end-of-region calls `merge(target)`. The merge concatenates
/// channel 0's commands first, then channel 1's — bg under content
/// across the whole region, regardless of cell submission order.
///
/// Typical usage:
/// ```zig
/// var splitter: DrawListSplitter =.{};
/// defer splitter.deinit(alloc);
/// try splitter.split(alloc, 2);
/// for (cells) |cell| {
///     splitter.setCurrentChannel(0);
///     if (splitter.getCurrentChannel()) |dl| dl.addRectFilled(alloc, cell.rect, bg);
///     splitter.setCurrentChannel(1);
///     if (splitter.getCurrentChannel()) |dl| dl.addText(alloc, font, cell.text,...);
/// }
/// try splitter.merge(alloc, &window.draw_list);
/// ```
///
/// ImGui equivalent: `ImDrawListSplitter` (imgui_draw.cpp). The
/// zimr API factors `target` out of `merge` (ImGui ties it to the
/// owning draw list); this lets a caller merge channels into a
/// different draw list than the recording originated from, which
/// can be useful for compositing into parent windows' draw lists.
pub const DrawListSplitter = struct {
    /// Side channels. Each channel records commands independently.
    /// Order in this slice determines the final order of commands
    /// in the target DrawList on `merge()` — channel 0's commands
    /// go first, channel N-1's last.
    channels: ArrayList(DrawList) = .empty,

    /// Index of the active channel — the one `getCurrentChannel()`
    /// returns. Initialized to 0 by `split()`. `setCurrentChannel`
    /// updates it.
    current: usize = 0,

    /// Free channel storage. Pass the same allocator used by
    /// `split()` (and by any `add*` calls on the channels).
    pub fn deinit(self: *DrawListSplitter, alloc: Allocator) void {
        for (self.channels.items) |*ch| {
            ch.deinit(alloc);
        }
        self.channels.deinit(alloc);
    }

    /// Allocate `n` empty channels. Calling `split` again is
    /// allowed — the new layout supersedes any previous one (any
    /// unmerged commands are lost; channel storage is freed first).
    /// Errors propagate from the underlying ArrayList allocations.
    pub fn split(
        self: *DrawListSplitter,
        alloc: Allocator,
        n: usize,
    ) !void {
        for (self.channels.items) |*ch| {
            ch.deinit(alloc);
        }
        self.channels.clearRetainingCapacity();
        try self.channels.ensureTotalCapacity(alloc, n);
        var i: usize = 0;
        while (i < n) : (i += 1) {
            self.channels.appendAssumeCapacity(.{});
        }
        self.current = 0;
    }

    /// Switch the active channel. Subsequent `getCurrentChannel()`
    /// calls return this index's DrawList. Out-of-range indices
    /// are clamped to the last channel — defensive default so a
    /// misconfigured call doesn't crash the render path. When the
    /// splitter has no channels (split() not yet called), `current`
    /// is forced to 0.
    pub fn setCurrentChannel(self: *DrawListSplitter, i: usize) void {
        if (self.channels.items.len == 0) {
            self.current = 0;
            return;
        }
        self.current = @min(i, self.channels.items.len - 1);
    }

    /// Get the active channel's DrawList — call `add*` methods on
    /// the returned pointer. Returns null when the splitter has
    /// no channels (split() wasn't called, or was called with n=0).
    pub fn getCurrentChannel(self: *DrawListSplitter) ?*DrawList {
        if (self.current >= self.channels.items.len) {
            return null;
        }
        return &self.channels.items[self.current];
    }

    /// Concatenate all channel command lists into `target` in
    /// channel-index order (channel 0 first, channel N-1 last),
    /// then empty each channel (clearRetainingCapacity — the
    /// channels remain valid for further `add*` calls). The
    /// splitter is ready for a new `split()` after this.
    /// Allocates only to grow `target.cmds`. Errors propagate
    /// from the underlying append.
    pub fn merge(
        self: *DrawListSplitter,
        alloc: Allocator,
        target: *DrawList,
    ) !void {
        for (self.channels.items) |*ch| {
            try target.cmds.appendSlice(alloc, ch.cmds.items);
            ch.cmds.clearRetainingCapacity();
        }
    }
};

/// Allocator-bound handle over a `*DrawList`. What the user
/// receives from `Ui.getBackgroundDrawList` / `Ui.getForegroundDrawList`.
/// The underlying `DrawList.add*` methods each take an allocator as
/// their first argument (the UiContext's frame arena). Plumbing
/// that allocator through every call site at user code would be
/// noisy and error-prone - pair it once with the list pointer here
/// and the user can write `dl.addRectFilled(rect, col)` with no
/// allocator visible.
/// The most common 8 add* methods are wrapped directly. For less-
/// common ones (textured quads, polylines, bezier curves, ellipses,
/// multi-color gradients, clip rects, etc.) reach through `dl.list`
/// and pass `dl.gpa` explicitly:
///     dl.list.addEllipse(dl.gpa, center, radii, col, 0, 32, 1);
/// The handle is cheap (one pointer + one Allocator interface =
/// 24 bytes) and is meant to be created per call site and dropped.
pub const DrawListHandle = struct {
    list: *DrawList,
    gpa: Allocator,

    pub fn addRectFilled(
        self: DrawListHandle,
        r: Rectangle,
        col: Color,
    ) void {
        self.list.addRectFilled(self.gpa, r, col.toWire());
    }

    pub fn addRectOutline(
        self: DrawListHandle,
        r: Rectangle,
        col: Color,
    ) void {
        self.list.addRectOutline(self.gpa, r, col.toWire());
    }

    // ---- Unified draw2d surface (see notes/drawing_api.md). Thin wrappers over
    // the add* methods above: Color at the surface (packed via toWire internally),
    // the frame-arena allocator held by the handle. Rounding + outline thickness
    // beyond 1px are deferred on the DrawList (the add* rect methods don't take
    // them yet); `line`/`text` are deferred pending the plot-sink reconciliation.

    /// Fill or 1px-outline a `Rectangle`.
    pub fn rect(self: DrawListHandle, r: Rectangle, opts: draw2d.RectOpts) void {
        if (opts.outline <= 0) {
            self.addRectFilled(r, opts.color);
        } else {
            self.addRectOutline(r, opts.color);
        }
    }

    /// `rect` twin taking loose numbers.
    pub fn rectXYWH(
        self: DrawListHandle,
        x: f32,
        y: f32,
        w: f32,
        h: f32,
        opts: draw2d.RectOpts,
    ) void {
        self.rect(.{ .x = x, .y = y, .width = w, .height = h }, opts);
    }

    /// Fill or stroke a circle. `segments` and outline thickness pass straight
    /// through to the underlying add* methods.
    pub fn circle(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        opts: draw2d.CircleOpts,
    ) void {
        if (opts.outline <= 0) {
            self.list.addCircleFilled(self.gpa, center, radius, opts.color.toWire(), opts.segments);
        } else {
            self.list.addCircle(self.gpa, center, radius, opts.color.toWire(), opts.outline, opts.segments);
        }
    }

    /// Unified primitive: a thick line from `a` to `b`.
    pub fn line(self: DrawListHandle, a: Vec2, b: Vec2, opts: draw2d.LineOpts) void {
        self.addLine(a, b, opts.color, opts.thickness);
    }

    /// Unified primitive: a filled triangle.
    pub fn triangle(
        self: DrawListHandle,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        opts: draw2d.ShapeOpts,
    ) void {
        self.list.addTriangleFilled(self.gpa, a, b, c, opts.color.toWire());
    }

    /// Unified primitive: draw `str` at `pos`. `opts.font` null → the UiContext's
    /// default font; recorded for replay, so a non-null font must outlive the frame.
    pub fn text(
        self: DrawListHandle,
        pos: Vec2,
        str: []const u8,
        opts: draw2d.TextOpts,
    ) void {
        self.list.addText(self.gpa, opts.font, str, pos, opts.size, opts.spacing, 0, opts.color.toWire());
    }

    /// Unified primitive: draw `sprite` at `dst`. Recorded now; residency (upload
    /// + cache) resolves at replay via the backend's `image`, so the sprite's
    /// pixels must outlive the frame (true for the per-frame DrawList).
    pub fn image(
        self: DrawListHandle,
        dst: Rectangle,
        sprite: Sprite,
        opts: draw2d.ImageOpts,
    ) void {
        self.list.addSpriteQuad(self.gpa, dst, sprite, opts);
    }

    /// `image` twin taking loose numbers for the destination.
    pub fn imageXYWH(
        self: DrawListHandle,
        x: f32,
        y: f32,
        w: f32,
        h: f32,
        sprite: Sprite,
        opts: draw2d.ImageOpts,
    ) void {
        self.image(.{ .x = x, .y = y, .width = w, .height = h }, sprite, opts);
    }

    pub fn addText(
        self: DrawListHandle,
        s: []const u8,
        pos: Vec2,
        size: f32,
        col: Color,
    ) void {
        // Spacing + line_spacing default to the same values
        // `text.draw` would use - 1 px char spacing, no extra line
        // height beyond the font's native baseline-to-baseline.
        // Pass null for `font` so the renderer uses the default
        // font cached on the UiContext.
        self.list.addText(self.gpa, null, s, pos, size, 1, 0, col.toWire());
    }

    pub fn addLine(
        self: DrawListHandle,
        p0: Vec2,
        p1: Vec2,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addLine(self.gpa, p0, p1, col.toWire(), thickness);
    }

    pub fn addCircle(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        col: Color,
        thickness: f32,
    ) void {
        // n_segments = 0 → DrawList picks an automatic count based
        // on radius (~0.30 px error against a true circle).
        self.list.addCircle(self.gpa, center, radius, col.toWire(), thickness, 0);
    }

    pub fn addCircleFilled(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        col: Color,
    ) void {
        self.list.addCircleFilled(self.gpa, center, radius, col.toWire(), 0);
    }

    pub fn addTriangle(
        self: DrawListHandle,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addTriangle(self.gpa, a, b, c, col.toWire(), thickness);
    }

    pub fn addTriangleFilled(
        self: DrawListHandle,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        col: Color,
    ) void {
        self.list.addTriangleFilled(self.gpa, a, b, c, col.toWire());
    }

    pub fn addQuadFilled(
        self: DrawListHandle,
        a: Vec2,
        b: Vec2,
        c: Vec2,
        d: Vec2,
        col: Color,
    ) void {
        self.list.addQuadFilled(self.gpa, a, b, c, d, col.toWire());
    }

    pub fn addPolyline(
        self: DrawListHandle,
        points: []const Vec2,
        col: Color,
        thickness: f32,
        closed: bool,
    ) void {
        self.list.addPolyline(self.gpa, points, col.toWire(), thickness, closed);
    }

    /// Stroked arc, radians, 0=+X, CCW positive. `segments = 0`
    /// auto-derives from radius + arc fraction.
    pub fn addArc(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        a0: f32,
        a1: f32,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addArc(self.gpa, center, radius, a0, a1, col.toWire(), thickness, 0);
    }

    /// Filled arc / pie slice. Pass `a1 - a0 == 2π` for a full
    /// disk equivalent to `addCircleFilled`.
    pub fn addArcFilled(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        a0: f32,
        a1: f32,
        col: Color,
    ) void {
        self.list.addArcFilled(self.gpa, center, radius, a0, a1, col.toWire(), 0);
    }

    /// Filled convex polygon, fan-triangulated from points[0].
    pub fn addPolygon(
        self: DrawListHandle,
        points: []const Vec2,
        col: Color,
    ) void {
        self.list.addPolygon(self.gpa, points, col.toWire());
    }

    pub fn addBezierCubic(
        self: DrawListHandle,
        p1: Vec2,
        p2: Vec2,
        p3: Vec2,
        p4: Vec2,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addBezierCubic(self.gpa, p1, p2, p3, p4, col.toWire(), thickness);
    }

    pub fn addNgon(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addNgon(self.gpa, center, radius, sides, rotation_rad, col.toWire(), thickness);
    }

    pub fn addNgonFilled(
        self: DrawListHandle,
        center: Vec2,
        radius: f32,
        sides: u32,
        rotation_rad: f32,
        col: Color,
    ) void {
        self.list.addNgonFilled(self.gpa, center, radius, sides, rotation_rad, col.toWire());
    }

    pub fn addEllipse(
        self: DrawListHandle,
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: Color,
        thickness: f32,
    ) void {
        self.list.addEllipse(self.gpa, center, radius_h, radius_v, col.toWire(), thickness);
    }

    pub fn addEllipseFilled(
        self: DrawListHandle,
        center: Vec2,
        radius_h: f32,
        radius_v: f32,
        col: Color,
    ) void {
        self.list.addEllipseFilled(self.gpa, center, radius_h, radius_v, col.toWire());
    }
};

/// All per-scope layout state. One LayoutScope lives on each
/// `Window`; `beginChild` pushes the parent's onto a stack and
/// installs a fresh one for the child. Centralizing this state
/// fixes the four turn-308 bugs (sameLine, layout_origin, splitter
/// pos, treePop indent) and preempts the same pattern of bug for
/// every future scope-aware feature (per-child scrolling,
/// docking, treeNode draw_lines).
/// Field group rationale:
/// - `origin` - where THIS scope's content area starts. For a
///   top-level window, this is the inside of the window padding.
///   For a child, the child rect's inner-top-left. advanceLayout's
///   row-reset uses this; sameLine with `offset_x > 0` uses this.
/// - `cursor_pos` - where the NEXT widget will be placed. Most
///   widgets read this at submission start and write to it at end
///   via advanceLayout.
/// - `cursor_max` - bottom-right of laid-out content. Used for
///   auto-fit window sizing and scroll_max computation.
/// - `cursor_pos_prev_line` / `prev_line_height` - track the
///   previous row's right edge and tallest widget. `sameLine`
///   reads these to restore the cursor onto the previous row
///   instead of the wrap-advanced position. Matches imgui's
///   `DC.CursorPosPrevLine` / `DC.PrevLineSize.y`.
/// - `line_height` - tallest widget on the CURRENT row,
///   accumulating as sameLine adds more widgets. Reset to 0
///   when the cursor wraps to the next row. Matches imgui's
///   `DC.CurrLineSize.y`.
/// - `indent_x` - current indent depth. `treeNode` / `indent()`
///   push; `treePop` / `unindent()` pop. Added to `origin[0]`
///   on row wrap.
/// - `last_item_*` - bookkeeping for `IsItemHovered` /
///   `IsItemActive` / `IsItemEdited` etc. Set by widgets on
///   submission.
pub const LayoutScope = struct {
    /// Top-left of this scope's content area (screen px). COUPLED to
    /// `cursor_pos`: row 0 is placed at `cursor_pos`, but `advanceLayout`
    /// resets every later row's X to `origin[0] + indent - scroll_x`. So
    /// `origin` and `cursor_pos` MUST be initialized together — set one
    /// without the other and rows 2+ snap to the stale origin (default
    /// {0,0} = canvas left). Always init via `resetContentLayout`, never
    /// by assigning `cursor_pos` alone (that was the combo/popup bug).
    origin: Vec2 = .{ 0, 0 },
    /// Bottom-right of this scope's content area (screen px).
    /// Used by `getContentRegionAvail` and `beginChild`'s auto-fill
    /// (size=0 dimension) to know HOW MUCH room is available without
    /// reaching past the scope. Imgui: `ImGuiWindow.WorkRect.Max`.
    /// At window-open this is the inner bottom-right (window rect
    /// minus padding + title bar). At `beginChild` it's the child
    /// rect's inner bottom-right. Restored on `endChild` along
    /// with the rest of `LayoutScope`.
    /// Without this, nested children's auto-fill / region-avail
    /// queries reached past the immediate parent into the top-level
    /// window - caused the right-pane spillover where
    /// the editor pane (auto-fill width=0) inside the right pane
    /// rendered past the right pane's right edge.
    work_rect_max: Vec2 = .{ 0, 0 },

    /// Where the next widget will be placed.
    cursor_pos: Vec2 = .{ 0, 0 },
    /// Bottom-right of all laid-out content.
    cursor_max: Vec2 = .{ 0, 0 },

    /// (right edge of last item, top of current row). `sameLine`
    /// restores cursor X from `[0]`, Y from `[1]`. Imgui:
    /// `DC.CursorPosPrevLine`.
    cursor_pos_prev_line: Vec2 = .{ 0, 0 },
    /// Tallest widget on the CURRENT row. Accumulates across
    /// sameLine submissions. Reset to 0 by advanceLayout when
    /// the row wraps.
    line_height: f32 = 0,
    /// Tallest widget on the PREVIOUS row. Set by advanceLayout
    /// when the row wraps; read by `sameLine` to restore
    /// `line_height` for ongoing accumulation on the row that
    /// sameLine just rejoined. Imgui: `DC.PrevLineSize.y`.
    prev_line_height: f32 = 0,

    /// Indent depth. Added to `origin[0]` on every row wrap.
    indent_x: f32 = 0,

    /// Last-item bookkeeping - set by widgets, queried by
    /// `isItemHovered` / `isItemActive` / etc.
    last_item_id: Id = 0,
    last_item_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
    last_item_hovered: bool = false,
    last_item_edited: bool = false,
    last_item_clicked: bool = false,
    last_item_activated: bool = false,
    last_item_deactivated: bool = false,
    last_item_deactivated_after_edit: bool = false,
};

/// Per-window flags. These control window-category behavior (is it
/// a child? a popup? a tooltip?) and what chrome to render (title
/// bar, move-on-drag, resize grips). Default-init = "ordinary
/// user-created top-level window."
/// Maps to imgui's `ImGuiWindowFlags_*` bit constants, but expressed
/// as a Zig struct-of-bools so each flag is independently
/// type-checked. Bit-flag enums save 8 bytes; the type safety is
/// worth more than 8 bytes in our case.
pub const WindowFlags = struct {
    /// True if this window was opened via `beginChild`. Affects
    /// chrome (no title bar, no move handle) and how `endFrame`
    /// renders it (children submit into the parent's draw list via
    /// clip-rect stacking, not as separate top-level windows).
    is_child: bool = false,

    // ---- Chrome suppression -------------------------
    // Each flag suppresses one piece of window chrome. Combine freely;
    // a borderless overlay is `.{.no_title_bar = true,.no_resize = true,
    // .no_move = true,.no_background = true }`. Matches imgui's
    // `ImGuiWindowFlags_NoTitleBar` / `NoResize` / `NoMove` /
    // `NoCollapse` / `NoBackground` / `NoScrollbar` semantics.

    /// Skip the title-bar rect + text rendering AND the title-bar
    /// drag handle. Content origin shifts up by `title_bar_height`.
    /// Useful for fullscreen overlays and toolbars.
    no_title_bar: bool = false,
    /// Skip the corner resize grip render + the resize drag hit-test.
    /// The window keeps whatever size it was created with (or last
    /// got via `setNextWindowSize`). Pair with explicit `initial_size`
    /// to lock window dimensions.
    no_resize: bool = false,
    /// Skip the title-bar drag hit-test. The title bar still renders
    /// (unless `no_title_bar` is also set); only the drag-to-move
    /// behavior is disabled. Useful for fixed-position chrome.
    no_move: bool = false,
    /// Reserve a flag slot for "skip the collapse arrow / toggle."
    /// Currently a NO-OP: zimr doesn't yet ship a collapsible-window
    /// mechanism (windows are always uncollapsed). Setting this is
    /// future-proof — once collapse lands, the flag attaches at the
    /// new hit-test site automatically.
    no_collapse: bool = false,
    /// Skip the window-background `drawRectFilled` before content.
    /// Use for transparent overlays where the content provides its
    /// own backdrop (e.g. a HUD over a 3D scene). The frame border,
    /// title bar, and grip still render — those are separate
    /// chrome elements; suppress them with the matching flags if you
    /// want a fully invisible window.
    no_background: bool = false,
    /// Skip scrollbar render AND skip wheel-scroll consumption when
    /// this window is hovered. Content that overflows the window
    /// remains clipped, just not scrollable. Useful for fixed
    /// dashboards / HUD windows that should never scroll.
    no_scrollbar: bool = false,

    // ---- Sizing -------------------------------------
    // Three sizing-related flags mirroring imgui's
    // `ImGuiWindowFlags_AlwaysAutoResize` / `_AlwaysUseWindowPadding` /
    // `_NoSavedSettings`.

    /// Every frame, shrink-to-fit content regardless of any previous
    /// `user_resized` state. Persisted size is ignored when this
    /// flag is set on first submission. Combine with `no_resize`
    /// for a pure content-fit overlay (toast notification, status
    /// pill). Imgui: `ImGuiWindowFlags_AlwaysAutoResize`.
    always_auto_resize: bool = false,
    /// Reserved slot. Top-level windows always pad with
    /// `style.window_padding` today, so this flag is currently a
    /// NO-OP for them. For children, the equivalent already exists
    /// as `ChildFlags.always_use_window_padding` (use that path
    /// today). Wiring this flag for top-level windows in the future
    /// would mean "even when other layout overrides shrink the
    /// inner rect to zero, keep the padding gap visible" — not yet
    /// a real failure mode. Imgui:
    /// `ImGuiWindowFlags_AlwaysUseWindowPadding`.
    always_use_window_padding: bool = false,
    /// Exclude this window from `.zon` persistence. The serializer
    /// skips windows with this flag set; the loader can't see them
    /// (they were never written). Use for transient inspector
    /// windows, debug overlays, or any UI element whose
    /// pos/size shouldn't survive across sessions. Imgui:
    /// `ImGuiWindowFlags_NoSavedSettings`.
    no_saved_settings: bool = false,

    // ---- Focus + ordering ---------------------------
    // All four flags are RESERVED slots today. zimr's render order is
    // purely submission-driven (no internal z-order sort) and there's
    // no keyboard-nav state machine, so the imgui semantics these
    // mirror have nothing to gate against yet. Same convention as
    // `no_collapse` (P5.1) and `always_use_window_padding` (P5.2):
    // reserve the API surface now, wire when the mechanism lands.

    /// Skip auto-focus-on-first-appearance. NO-OP today: zimr does
    /// not currently auto-focus newly-appeared windows (a window's
    /// `focused_window_id` only flips on click / `setNextWindowFocus`).
    /// Wires when first-frame-detection + auto-focus default lands.
    /// Imgui: `ImGuiWindowFlags_NoFocusOnAppearing`.
    no_focus_on_appearing: bool = false,
    /// Focusing this window does NOT reorder it to the top of the
    /// render stack. NO-OP today: zimr renders windows in the order
    /// the caller submits them — no internal focus-driven reorder
    /// exists yet. Wires when a `last_focus_frame: u64` is added to
    /// `Window` and `endFrame` sorts `frame_windows` by it.
    /// Imgui: `ImGuiWindowFlags_NoBringToFrontOnFocus`.
    no_bring_to_front_on_focus: bool = false,
    /// Skip this window when navigating with the keyboard. NO-OP
    /// today: zimr has no keyboard-nav state machine. Wires when
    /// nav infrastructure lands. Imgui: `ImGuiWindowFlags_NoNavFocus`.
    no_nav_focus: bool = false,
    /// Don't accept keyboard nav inputs targeted at this window.
    /// NO-OP today (same reason as `no_nav_focus`). Wires when
    /// nav infrastructure lands. Imgui: `ImGuiWindowFlags_NoNavInputs`.
    no_nav_inputs: bool = false,

    // ---- Input passthrough --------------------------
    // Two flags that make a window "see-through" for input. Used for
    // HUD overlays, decorative panels, and any UI element that
    // shouldn't capture mouse interaction over content underneath.
    // Mechanism: zimr's hover resolution lives in `openWindow` — each
    // submitted window unconditionally claims hover if the mouse is
    // inside its outer rect, and submission order is back-to-front
    // (the LAST overwriter wins). These flags skip the claim, so a
    // covered window underneath keeps the hover. Since every other
    // interaction (title-drag, resize, click, wheel) gates on
    // `hovered_window_id == w.id`, the flagged window becomes
    // mechanically passthrough.

    /// Skip mouse input claiming for this window. Hover, clicks, and
    /// wheel events pass through to whatever window is underneath.
    /// Imgui: `ImGuiWindowFlags_NoMouseInputs`.
    no_mouse_inputs: bool = false,
    /// Skip ALL input claiming. Today this is equivalent to
    /// `no_mouse_inputs` (zimr has no keyboard-nav yet); future-proof
    /// for when nav lands. Imgui: `ImGuiWindowFlags_NoInputs`.
    no_inputs: bool = false,

    // ---- Document + menubar markers ----------------
    // Two small flags that complete the P5 wave (horizontal scroll
    // is the remaining piece, shipped separately in P5.5b).

    /// Required for `beginMenuBar` to return true. Without it,
    /// `beginMenuBar` returns false and the body is skipped — matches
    /// imgui's `ImGuiWindowFlags_MenuBar` contract. Default false so
    /// existing callers that DIDN'T use menu bars continue to work;
    /// callers that DID call `beginMenuBar` without the flag (which
    /// previously opened unconditionally) need to set the flag now.
    /// Imgui: `ImGuiWindowFlags_MenuBar`.
    menu_bar: bool = false,
    /// Render a "modified" marker (" *") after the title text in the
    /// title bar. Pure visual; no behavioral side-effects. Use for
    /// text editors / document panels with unsaved changes.
    /// Imgui: `ImGuiWindowFlags_UnsavedDocument`.
    unsaved_document: bool = false,

    // ---- Horizontal scrollbar ----------------------

    /// Opt into a horizontal scrollbar when content overflows the
    /// window's width. Without this flag, X-overflow is silent —
    /// content gets clipped at the window's right edge. With it,
    /// `scroll_max_x > 0` triggers `renderScrollbarX` on the
    /// window's bottom edge, and wheel-X gestures translate to
    /// `scroll_x` updates. Imgui:
    /// `ImGuiWindowFlags_HorizontalScrollbar`.
    horizontal_scrollbar: bool = false,
    // Future: popup, tooltip, modal, dock_node - added as features
    // land in Phases 2-5.
    // is_popup: bool = false,
    // is_tooltip: bool = false,
};

pub const Window = struct {
    id: Id,
    name_buf: [128]u8 = undefined,
    name_len: usize = 0,

    /// Top-left of the window in screen pixels.
    pos: Vec2,
    /// Window outer size in pixels (includes title bar).
    size: Vec2,

    /// All per-scope layout state - cursor, indent, last-item
    /// bookkeeping, etc. See `LayoutScope` for field-by-field docs.
    /// Each Window owns its layout outright - children are full
    /// `Window`s with their own `layout`, so the parent's `layout` is never mutated during
    /// a child's scope.
    layout: LayoutScope = .{},

    /// Frame counter at last submission. Future GC pass drops
    /// windows not seen in N frames; not implemented in.
    last_frame_active: u64 = 0,

    /// Per-window draw command list. Widgets append commands during
    /// submission; the renderer replays the list at endFrame. Cleared
    /// (and the underlying memory recycled via the shared
    /// `UiContext.frame_arena`) at each `openWindow`. Default-inits
    /// because all storage is owned by the ctx-level arena, not the
    /// DrawList itself.
    draw_list: DrawList = .{},

    // ---- scroll + user-resize state
    /// Vertical scroll offset in pixels. When positive, content
    /// renders shifted UP by this amount; effectively the y-coord of
    /// the visible top edge inside the content area. Clamped to
    /// `[0, scroll_max_y]` at end-of-frame.
    scroll_y: f32 = 0,
    /// Maximum allowed `scroll_y` - recomputed every frame in
    /// `closeWindow` from `cursor_max.y - viewport_h`. Zero means
    /// content fits, no scrollbar shown.
    scroll_max_y: f32 = 0,
    /// P5.5b: horizontal scroll offset in pixels, analog
    /// of `scroll_y`. Content widgets read this via the layout
    /// origin shift in `openWindow`. Only meaningful when the
    /// window's `WindowFlags.horizontal_scrollbar` is set; otherwise
    /// stays at 0 and content beyond the window's right edge is
    /// clipped without scrollbar.
    scroll_x: f32 = 0,
    /// Maximum allowed `scroll_x` — analog of `scroll_max_y`.
    /// Recomputed every frame in `closeWindow` from
    /// `cursor_max.x - viewport_w` when the horizontal-scrollbar
    /// flag is set.
    scroll_max_x: f32 = 0,

    /// Touch-pan scroll bookkeeping.  When the user touch-drags
    /// across the window body and no widget claims the input,
    /// the gesture pans `scroll_y` (and `scroll_x` if H-scrollbar
    /// enabled).  Captured at gesture start so each frame's scroll
    /// can be computed from the absolute mouse delta, avoiding
    /// drift from per-frame integration.
    touch_pan_active: bool = false,
    touch_pan_start_mouse: Vec2 = .{ 0, 0 },
    touch_pan_start_scroll_y: f32 = 0,
    touch_pan_start_scroll_x: f32 = 0,
    /// True once the user has manually resized this window via the
    /// corner grip (or the user passed an explicit `setNextWindow
    /// Size`). When true, `closeWindow` skips the auto-fit grow
    /// behaviour so content overflow shows a scrollbar instead of
    /// silently re-growing the window.
    user_resized: bool = false,

    /// Only used by the tooltip-block window - stash points for the
    /// current_window / current_draw_list at `beginTooltip` so
    /// `endTooltip` can restore them. Untouched on normal windows.
    saved_window_ptr: ?*Window = null,
    saved_draw_list: ?*DrawList = null,

    // ---- Turn 309d: child-as-Window plumbing
    // Children are now full `Window` values stored in `ctx.windows`
    // keyed by their hashed ID (parent_id_stack ⊕ str_id). See
    // `beginChildImpl` / `endChildImpl` and the architecture tutorial
    // at `src/notes/architecture-imgui-vs-zimr.md` for the rationale.

    /// Window category + behavior flags. Top-level windows have all
    /// these false; children flip `is_child`; popups flip
    /// `no_title_bar` and `no_move`; etc. Matches imgui's
    /// `ImGuiWindowFlags` (we'll grow this as features land).
    flags: WindowFlags = .{},

    /// Parent window's persistent ID (only set when `flags.is_child`).
    /// Null for top-level windows. Used by `endChild` to find the
    /// parent on the window stack for cursor advancement, and by
    /// future docking / nav features. Imgui equivalent: `ParentWindow`
    /// (a direct pointer; we use ID-by-value to keep `Window` POD-ish
    /// and avoid lifetime hazards if the parent is GC'd).
    parent_id: ?Id = null,

    /// docking attachment. Non-null when
    /// this window has been routed into a dock leaf - either by
    /// the user dragging it into a dock zone, or programmatically
    /// via `dockBuilderDockWindow`. When set, `findOrCreateWindow`
    /// → `openWindow` route through `beginDockedWindow` instead of
    /// rendering free-floating chrome; the window's content
    /// occupies the leaf's rect minus the tab strip at top.
    /// Cleared by `undockWindow` or by docking elsewhere. The
    /// dock layer maintains the inverse mapping (leaf → window list)
    /// so the routing here is informational rather than canonical.
    /// Restored from persistence via `PersistedWindow.dock_node_id`
    /// (when 5.5k lands).
    dock_node_id: ?Id = null,
    /// Frame counter of this window's most recent focus; floating windows render
    /// sorted by this (ascending) so the focused one draws on top.
    last_focus_frame: u64 = 0,
    /// True if this window hosts a dockSpace; kept out of the focus reorder so it
    /// stays behind its docked children.
    hosts_dockspace: bool = false,

    /// pos/size at the moment this window
    /// first docked. Used to restore the window to its pre-dock
    /// rect when it undocks - without this, an undocked window
    /// stays at the leaf's rect, which is rarely where the user
    /// wants it.
    /// `null` means "this window has never been docked, or it has
    /// been undocked and restored, no stash to apply."
    /// imgui parallel: `AuthorityForPos/Size` 3-bit fields on
    /// ImGuiDockNode track ownership granularly per-axis. Our
    /// model is simpler - just stash + restore as a unit. Loses
    /// the "I docked, then moved the leaf, now undocking should
    /// keep the leaf's pos" nuance; gains a much simpler mental
    /// model. Filed as JUSTIFIED divergence in the audit.
    pre_dock_pos: ?Vec2 = null,
    pre_dock_size: ?Vec2 = null,

    pub fn name(self: *const Window) []const u8 {
        return self.name_buf[0..self.name_len];
    }
};

// ============================================================================
// HoveredFlags / FocusedFlags - query-tuning flags for the `Ex` variants
// of `isItemHovered`, `isWindowHovered`, `isWindowFocused`.
// ============================================================================
// Maps to imgui's `ImGuiHoveredFlags_*` + `ImGuiFocusedFlags_*` bit
// constants. pull-forward - these gate behavior
// the docking system relies on ("is the dockspace's host OR any of
// its docked windows focused?" → `FocusedFlags{.root_and_child_windows }`).
// Hover-delay fields (delay_short, delay_normal, no_shared_delay,
// stationary, for_tooltip) are DEFERRED until a follow-up turn lands
// the per-item-id hover-start timestamp infra. Adding the flag with
// no implementation would be a footgun; we'd rather have the API
// surface track what's actually wired.
// Many flags (allow_when_blocked_by_*, allow_when_overlapped_by_*,
// allow_when_disabled, no_nav_override) are accepted but EFFECTIVELY
// NO-OP today: zimr doesn't yet implement modal-popup blocking,
// active-item occlusion, or keyboard-nav state. When those land,
// the flag's effect attaches at that line - no API churn for users
// who set the flag now. Doc-string on each field calls this out.

/// Flag set for `isItemHovered` and `isWindowHovered`. All
/// default to false; pass `.{}` for "no special flags."
/// Zig idiom: struct-of-bools rather than imgui's bit-flag enum.
/// Each flag is independently type-checked at the call site; you
/// cannot mistype `ChildWindwos` (typo) and have it silently default
/// to 0 - the compiler catches it.
pub const HoveredFlags = struct {
    /// `isWindowHovered` only: return true if the mouse is over
    /// THIS window OR any of its child windows. Without this flag,
    /// a parent window is NOT considered hovered when the mouse is
    /// over one of its children (the child obstructs).
    child_windows: bool = false,
    /// `isWindowHovered` only: walk up the `parent_id` chain to
    /// the root before testing. Combined with `child_windows`,
    /// gives "any window in my hierarchy is hovered."
    root_window: bool = false,
    /// `isWindowHovered` only: return true if ANY window in this
    /// `UiContext` is hovered (mouse over any window's outer rect).
    /// IMPORTANT: do NOT use this to gate low-level input dispatch;
    /// for "should I capture this click?" use the relevant scope's
    /// per-scope hovered state.
    any_window: bool = false,
    /// `isWindowHovered` only: don't consider popup hierarchy
    /// when traversing (treat popups as independent windows even
    /// when combined with `child_windows`/`root_window`). No-op
    /// today until popups gain a proper hierarchy link.
    no_popup_hierarchy: bool = false,

    /// Return true even if a popup window normally blocks access
    /// to this item/window. **NO-OP today** - popup-blocking is
    /// not yet implemented; landed when modals land.
    allow_when_blocked_by_popup: bool = false,
    /// Return true even if an `active_id` is set on a DIFFERENT
    /// item. Useful for drag-and-drop patterns (source stays
    /// "hovered" so its preview tooltip can render while dragging).
    /// **NO-OP today** - active-id occlusion is not yet implemented.
    allow_when_blocked_by_active_item: bool = false,
    /// `isItemHovered` only: return true even if the item uses
    /// `setNextItemAllowOverlap` and is overlapped by a later item.
    /// **NO-OP today** - `setNextItemAllowOverlap` is filed in
    /// .
    allow_when_overlapped_by_item: bool = false,
    /// `isItemHovered` only: return true even if obstructed by
    /// a later window covering the position. **NO-OP today**
    /// per-item z-order obstruction isn't tracked; only per-window
    /// is (via `ctx.hovered_window_id`).
    allow_when_overlapped_by_window: bool = false,
    /// `isItemHovered` only: return true even if the item is
    /// disabled. **NO-OP today** - disabled-state hover gating
    /// isn't yet implemented.
    allow_when_disabled: bool = false,
    /// `isItemHovered` only: disable using keyboard/gamepad nav
    /// state, always query mouse. **NO-OP today** - keyboard nav
    /// affecting hover is not yet implemented.
    no_nav_override: bool = false,

    /// Convenience: "any window hovered without occlusion checks."
    /// Equivalent to imgui's `ImGuiHoveredFlags_RectOnly`.
    pub const rect_only: HoveredFlags = .{
        .allow_when_blocked_by_popup = true,
        .allow_when_blocked_by_active_item = true,
        .allow_when_overlapped_by_item = true,
        .allow_when_overlapped_by_window = true,
    };
    /// Convenience: walk to root AND include all children.
    /// Equivalent to imgui's `ImGuiHoveredFlags_RootAndChildWindows`.
    pub const root_and_child_windows: HoveredFlags = .{
        .root_window = true,
        .child_windows = true,
    };
};

/// Flag set for `isWindowFocused`. Same hierarchy-traversal
/// semantics as `HoveredFlags`'s window-traversal fields.
pub const FocusedFlags = struct {
    /// True if THIS window OR any child has focus. Without it, a
    /// parent reports unfocused when focus is on a child.
    child_windows: bool = false,
    /// Walk to root before testing. With `child_windows`: "is
    /// anything in my hierarchy focused?"
    root_window: bool = false,
    /// True if ANY window has focus. Note: in a frame with no
    /// window clicks yet, focus may be on a previously-focused
    /// window from a prior frame - this flag reflects the
    /// persistent state, not "did focus change this frame."
    any_window: bool = false,
    /// Don't consider popup hierarchy. Same no-op caveat as
    /// `HoveredFlags.no_popup_hierarchy`.
    no_popup_hierarchy: bool = false,

    /// Convenience: walk to root AND include all children.
    /// Equivalent to imgui's `ImGuiFocusedFlags_RootAndChildWindows`.
    pub const root_and_child_windows: FocusedFlags = .{
        .root_window = true,
        .child_windows = true,
    };
};

// ============================================================================
// ItemFlags. Cross-widget behavior flags that
// can be pushed onto a stack via `pushItemFlag`/`popItemFlag`, or
// passed as `opts` to individual widgets that accept them.
// Imgui's `ImGuiItemFlags` is internal-flagged but exposed via
// BeginDisabled/EndDisabled and `PushItemFlag`/`PopItemFlag`. We
// model it the same way: a stack of `ItemFlags` lives on
// `UiContext.item_flags_stack`; widgets read the effective stack-
// merged flags via `currentItemFlags()`. Pre-existing
// `beginDisabled`/`endDisabled` is a special-case wrapper around
// `pushItemFlag(.{.disabled = true })` for ergonomics + back-compat.
// Field-by-field status:
// - `disabled` WIRED via the existing `disabled_count` path.
//                      Pushing flags-with-`.disabled = true` is
//                      equivalent to `beginDisabled(true)`.
// - `button_repeat` WIRED for button-family widgets that consult
//                      `currentItemFlags()` (button, arrowButton).
// - `allow_overlap` WIRED: tells `*HoveredImpl` to keep
//                      reporting the item as hovered even when a
//                      later item also passes its hit-test.
// - `no_tab_stop` PLUMBED (no-op). Wires up when keyboard nav
//                      lands (+).
// - `no_nav` PLUMBED. Same waiting room as no_tab_stop.
// - `no_nav_default_focus` PLUMBED.
// - `auto_close_popups` WIRED for menuItem + selectable
//                      controls whether the parent popup auto-closes
//                      on activation. Default ON in imgui; we use
//                      explicit bool so the default-on case is
//                      `auto_close_popups: bool = true` rather than
//                      "no flag = behavior X".

pub const ItemFlags = struct {
    /// Disable interactions for widgets submitted inside this scope.
    /// Visuals dim by `style.disabled_alpha`. Pre-existing
    /// `beginDisabled(true)` is equivalent to pushing this flag.
    disabled: bool = false,
    /// Disable keyboard tabbing onto items. **NOT YET WIRED**
    /// needs keyboard nav (+). Imgui:
    /// `ImGuiItemFlags_NoTabStop`.
    no_tab_stop: bool = false,
    /// Disable any focus mechanism on items. **NOT YET WIRED.**
    /// Imgui: `ImGuiItemFlags_NoNav`.
    no_nav: bool = false,
    /// Disable being a default-focus candidate. **NOT YET WIRED.**
    no_nav_default_focus: bool = false,
    /// Buttons behave as auto-repeat while held. Wired
    /// `button` checks `currentItemFlags().button_repeat`. Imgui:
    /// `ImGuiItemFlags_ButtonRepeat`.
    button_repeat: bool = false,
    /// MenuItem/Selectable auto-close their parent popup on
    /// activation. Default true to match imgui. Wired for
    /// menuItem. Imgui: `ImGuiItemFlags_AutoClosePopups`.
    auto_close_popups: bool = true,
    /// Allow this item to be hovered even when a later item
    /// overlaps it. WIRED - sets the item's
    /// `layout.last_item_allow_overlap` flag; the *next* item's
    /// hit-test honors it. Imgui: `ImGuiItemFlags_AllowOverlap`.
    allow_overlap: bool = false,
};

/// Merge two `ItemFlags` per imgui's stack semantics: each bool is
/// OR'd EXCEPT `auto_close_popups` which is AND'd (a parent disable
/// of auto-close propagates). Used internally when pushing onto
/// `item_flags_stack`.
pub fn mergeItemFlags(parent: ItemFlags, child: ItemFlags) ItemFlags {
    return .{
        .disabled = parent.disabled or child.disabled,
        .no_tab_stop = parent.no_tab_stop or child.no_tab_stop,
        .no_nav = parent.no_nav or child.no_nav,
        .no_nav_default_focus = parent.no_nav_default_focus or child.no_nav_default_focus,
        .button_repeat = parent.button_repeat or child.button_repeat,
        .auto_close_popups = parent.auto_close_popups and child.auto_close_popups,
        .allow_overlap = parent.allow_overlap or child.allow_overlap,
    };
}

// ============================================================================
// DragDropFlags.
// Replaces the old `DragDropSourceOpts { no_preview: bool }`. Same
// type covers source-side and target-side flags (imgui collapses
// them too into a single bitfield). Per Rule 14 a single
// struct-of-bools.
// Wired fields:
// - `source_no_preview` - was `DragDropSourceOpts.no_preview`,
//                            renamed to match imgui's
//                            `ImGuiDragDropFlags_SourceNoPreviewTooltip`.
// - `accept_no_draw_default_rect` - suppress the dashed
//                            highlight rect the target draws by
//                            default when a payload is over it.
// - `accept_before_delivery` - the target accepts mid-drag so the
//                            caller can peek-into the payload
//                            before the user releases. Already
//                            de-facto how zimr handles things
//                            (single-slot payload, last-frame-of-drag
//                            applies on release); this flag is a
//                            no-op in current impl but documents
//                            the future semantics.
// Plumbed/no-op fields:
// - `source_no_disable_hover` - needs hover-on-source-during-drag
//                            machinery (filed).
// - `source_no_hold_to_open_others` - hold-tree-to-open-it during
//                            drag isn't wired anyway.
// - `source_allow_null_id` - null-id sources (Text(), Image())
//                            need a per-position synthetic id;
//                            not wired.
// - `source_extern` - not relevant; we don't have external sources.
// - `payload_auto_expire` - payload-expiry-on-source-disappear
//                            (matches imgui 1.90.9+ rename of
//                            ImGuiDragDropFlags_SourceAutoExpirePayload).
// - `accept_no_preview_tooltip` - hide the source's tooltip while
//                            hovering this target; filed.
// - `accept_draw_as_hovered` - render the target item with its
//                            hover bg as if the user were hovering;
//                            useful for button-as-target. PARTIAL
//                            wire-up; only sets `last_item_hovered`
//                            on the target, doesn't yet override bg.

pub const DragDropFlags = struct {
    // ---- Source-side flags --------------------------------------
    /// Suppress the source's preview tooltip (would otherwise show
    /// a window following the cursor with the drag's payload
    /// representation). Wired - gates the tooltip rendering.
    /// Replaces old `DragDropSourceOpts.no_preview`. Imgui:
    /// `ImGuiDragDropFlags_SourceNoPreviewTooltip`.
    source_no_preview: bool = false,
    /// Keep the source item reporting hovered during the drag.
    /// **NOT YET WIRED.** Imgui:
    /// `ImGuiDragDropFlags_SourceNoDisableHover`.
    source_no_disable_hover: bool = false,
    /// Disable the "hold-over-a-tree-or-header-to-open-it" assist
    /// during drag. **NO-OP today** - hold-to-open isn't wired.
    source_no_hold_to_open_others: bool = false,
    /// Allow items without unique ids (Text, Image) to be drag
    /// sources. **NOT YET WIRED.** Imgui:
    /// `ImGuiDragDropFlags_SourceAllowNullID`.
    source_allow_null_id: bool = false,
    /// External-origin source (outside imgui). Not applicable in
    /// the zimr environment; **NO-OP**. Imgui:
    /// `ImGuiDragDropFlags_SourceExtern`.
    source_extern: bool = false,
    /// Auto-expire payload when the source stops being submitted.
    /// **NOT YET WIRED.** Imgui:
    /// `ImGuiDragDropFlags_PayloadAutoExpire`.
    payload_auto_expire: bool = false,

    // ---- Target-side flags --------------------------------------
    /// `acceptDragDropPayload` returns the payload BEFORE the user
    /// releases the mouse - caller can peek. **PARTIAL** - zimr's
    /// existing peek-before-release is implicit; this flag is
    /// documentation today. Imgui:
    /// `ImGuiDragDropFlags_AcceptBeforeDelivery`.
    accept_before_delivery: bool = false,
    /// Skip drawing the default dashed-highlight rect around the
    /// target while a payload is hovering. Wired - gates the
    /// `drawDragDropTargetHighlight` call. Imgui:
    /// `ImGuiDragDropFlags_AcceptNoDrawDefaultRect`.
    accept_no_draw_default_rect: bool = false,
    /// Hide the source's preview tooltip while hovering this
    /// target. **NOT YET WIRED.** Imgui:
    /// `ImGuiDragDropFlags_AcceptNoPreviewTooltip`.
    accept_no_preview_tooltip: bool = false,
    /// Render the target item with its hover-bg as if hovered.
    /// **PARTIAL** - `isItemHovered` returns true on the target
    /// when payload is over, but the bg-override isn't wired yet.
    /// Imgui: `ImGuiDragDropFlags_AcceptDrawAsHovered`.
    accept_draw_as_hovered: bool = false,

    /// Convenience: peek without redrawing. Imgui:
    /// `ImGuiDragDropFlags_AcceptPeekOnly`.
    pub const accept_peek_only: DragDropFlags = .{
        .accept_before_delivery = true,
        .accept_no_draw_default_rect = true,
    };
};

/// State saved on the `group_stack` when entering a `beginGroup`
/// scope. At `endGroup`, we compute the group's bounding rect from
/// `(saved_cursor_pos, cursor_max - saved_cursor_pos)` and write
/// it into `last_item_*` so subsequent `isItemHovered` queries see
/// the WHOLE group, not just the last widget in it.
/// Layout-wise the group is invisible - widgets inside it lay out
/// exactly as they would outside. endGroup just bookmarks the rect
/// and advances the parent cursor by the group's height.
pub const GroupState = struct {
    /// Cursor position when the group opened. Group's top-left.
    saved_cursor_pos: Vec2,
    /// `cursor_max` at the moment the group opened. endGroup
    /// computes the group rect from `cursor_max - cursor_max_at_start`.
    saved_cursor_max: Vec2,
    /// Saved row-height tracker - group widgets shouldn't inherit
    /// the row state from outside the group.
    saved_line_height: f32,
};

/// Per-popup persistent state stored in `UiContext.popup_open`.
/// Keyed by popup id. Presence in the map = "popup is open".
pub const PopupState = struct {
    /// Anchor point in screen pixels - popup's top-left when first
    /// opened. Captured at `openPopup` time (typically the mouse
    /// position).
    anchor: Vec2 = .{ 0, 0 },
    /// `frame_count` at the moment `openPopup` fired. Used to
    /// suppress the "click outside closes popup" check on the
    /// frame the popup just opened (the click that opened it would
    /// otherwise close it immediately).
    opened_at_frame: u64 = 0,
    /// True if this popup is a modal - renders a dim backdrop
    /// before the popup's chrome AND ignores click-outside-to-close
    /// (must be dismissed explicitly via `closeCurrentPopup` or by
    /// setting the `*open` bool to false on a `beginPopupModal`
    /// scope).
    is_modal: bool = false,
};

// ============================================================================
// TabBar. imgui-parity tab strip + per-tab state.
// Architecture:
// - `TabBarState` is the bar's persistent state (HashMap entry on
//   UiContext.tab_bar_state). Owns a list of `TabState` so the bar
//   remembers its tabs across frames - required for stable order +
//   reorder + GC.
// - `TabBarFrame` is per-frame scope state pushed onto
//   `UiContext.tab_bar_stack` between beginTabBar/endTabBar.
// - `TabBarFlags` / `TabItemFlags` are typed flag structs per Rule 14.
//   `TabFittingPolicy` is a mutually-exclusive enum (imgui's two
//   ImGuiTabBarFlags_FittingPolicy_* bits, collapsed).
// Per-frame flow:
//   beginTabBar: find/create state, mark `last_frame_active`, push frame.
//   beginTabItem: register/update TabState entry (or update existing).
//     Compute width. Render now if rendering pass. Return is_active.
//   endTabItem: pop id_stack if pushed.
//   endTabBar: GC stale tabs (last_frame_active < frame_count), render
//     separator, advance parent cursor, pop frame.
// Tab labels are stored inline in `TabState.label_buf` (32-byte cap)
// so the state survives across frames without depending on the
// caller's label-storage lifetime. Labels longer than 32 bytes
// truncate; rare in practice.

/// Mutually-exclusive policy for handling tabs that don't fit the
/// bar's width. imgui has two `ImGuiTabBarFlags_FittingPolicy_*`
/// bits; we collapse into an enum so the caller can't pick both.
pub const TabFittingPolicy = enum {
    /// Default: shrink tabs proportionally so they fit. Labels
    /// truncate when too narrow.
    resize_down,
    /// Keep tabs at their natural width; add scroll arrows when
    /// they overflow. **NOT YET IMPLEMENTED** as
    /// falls back to `resize_down` behavior.
    scroll,
};

/// Per-bar flags for `beginTabBar(str_id, opts: TabBarFlags)`.
/// Mirrors imgui's `ImGuiTabBarFlags` struct-of-bools.
///.
pub const TabBarFlags = struct {
    /// Let the user drag tabs to reorder them. Tabs marked
    /// `.no_reorder` stay in place; `.leading` / `.trailing` tabs
    /// reorder only within their pin section. **NOT YET WIRED**
    /// - filed for 3.6b follow-up turn. Imgui:
    /// `ImGuiTabBarFlags_Reorderable`.
    reorderable: bool = false,
    /// Auto-select new tabs as they appear (in addition to the
    /// always-on behavior of auto-selecting the very first tab on
    /// a fresh bar). Imgui: `ImGuiTabBarFlags_AutoSelectNewTabs`.
    auto_select_new_tabs: bool = false,
    /// Show a `...` button at the right edge that opens a popup
    /// listing all tabs. Useful when many tabs overflow.
    /// **NOT YET IMPLEMENTED.** Imgui:
    /// `ImGuiTabBarFlags_TabListPopupButton`.
    tab_list_popup_button: bool = false,
    /// Disable middle-click-to-close on tabs that have an
    /// `open_ptr`. Default false: middle-click closes.
    /// Imgui: `ImGuiTabBarFlags_NoCloseWithMiddleMouseButton`.
    no_close_with_middle_mouse_button: bool = false,
    /// Hide the left/right scroll arrows when overflow uses the
    /// scroll fitting policy. **NOT YET WIRED** - scroll policy
    /// itself isn't wired. Imgui:
    /// `ImGuiTabBarFlags_NoTabListScrollingButtons`.
    no_tab_list_scrolling_buttons: bool = false,
    /// Don't show the truncated-label tooltip on hover.
    /// **NOT YET WIRED** - needs hover-delay infra.
    no_tooltip: bool = false,
    /// Draw a 2px overline above the selected tab to emphasize it.
    /// Optional; matches imgui 1.91+ behavior. Off by default for
    /// the zimr theme's flatter look.
    draw_selected_overline: bool = false,
    /// How to handle the case where tabs don't fit. Default
    /// `.resize_down` - shrink tabs to fit. `.scroll` is **NOT
    /// YET WIRED** and falls back to `.resize_down`.
    fitting_policy: TabFittingPolicy = .resize_down,
};

/// Per-tab flags for `beginTabItem(label, open_ptr, opts: TabItemFlags)`.
/// Mirrors imgui's `ImGuiTabItemFlags`.
pub const TabItemFlags = struct {
    /// Show a `*` next to the label indicating unsaved changes.
    /// Wired: asterisk renders. Close-confirmation popup is the
    /// caller's responsibility (zimr doesn't pop one
    /// automatically; the caller can check `open_ptr.*` on click
    /// and show their own). Imgui:
    /// `ImGuiTabItemFlags_UnsavedDocument`.
    unsaved_document: bool = false,
    /// Force-select THIS tab on the frame the flag is set.
    /// One-shot - clear the flag next frame if you want repeated
    /// behavior. Imgui: `ImGuiTabItemFlags_SetSelected`.
    set_selected: bool = false,
    /// Disable middle-click-to-close on this specific tab,
    /// overriding the bar's setting.
    no_close_with_middle_mouse_button: bool = false,
    /// Don't push the tab's id onto `id_stack` while active. By
    /// default zimr pushes the tab id so widgets inside have
    /// per-tab ids. Set this if you've handled ID scoping
    /// manually. Imgui: `ImGuiTabItemFlags_NoPushId`.
    no_push_id: bool = false,
    /// Don't show truncated-label tooltip. **NOT YET WIRED.**
    no_tooltip: bool = false,
    /// Mark this tab as non-reorderable. Drag-reorder skips it.
    /// **NOT YET WIRED** - drag-reorder not yet implemented.
    no_reorder: bool = false,
    /// Pin this tab to the LEFT of the bar, before all
    /// reorderable tabs. Useful for hamburger / `+` buttons.
    /// Imgui: `ImGuiTabItemFlags_Leading`.
    leading: bool = false,
    /// Pin this tab to the RIGHT of the bar. Imgui:
    /// `ImGuiTabItemFlags_Trailing`.
    trailing: bool = false,
};

/// Inline label storage cap for `TabState`. 32 covers all
/// practical tab labels (file names, section names, "+"). Labels
/// longer than 32 truncate; the hashed `id` is the real identity,
/// so truncation only affects display.
pub const tab_label_cap: usize = 32;

/// Per-tab persistent state, lives on `TabBarState.tabs`.
pub const TabState = struct {
    /// Hashed id (label seed under the bar's id).
    id: Id,
    /// Inline label storage so the tab outlives the caller's
    /// label-string borrow. Required because tabs persist across
    /// frames but the caller's label might be a per-frame
    /// std.fmt.bufPrint result.
    label_buf: [tab_label_cap]u8 = undefined,
    label_len: usize = 0,
    /// Width recorded for the most recent `beginTabItem`. Used by
    /// the resize_down fitting policy.
    width: f32 = 0,
    /// `frame_count` of the most recent `beginTabItem`. Tabs
    /// stale by `endTabBar` are GC'd.
    last_frame_active: u64 = 0,
    /// Section pin (0 = leading, 1 = middle, 2 = trailing).
    /// Drives layout order within the bar.
    section: u8 = 1,
    /// Recorded `unsaved_document` flag this frame; drives the
    /// `*` decoration.
    unsaved: bool = false,
    /// Caller's `open_ptr`, cached so endTabBar can set it false
    /// after a close-click without having to re-look-up the
    /// caller's address. May be null.
    open_ptr_cache: ?*bool = null,
    /// Set to true by a close-X click or middle-click. Cleared
    /// at endTabBar after writing `open_ptr_cache.* = false`.
    want_close: bool = false,
};

/// Inline cap for `TabBarState.str_id_buf` and `parent_window_name_buf`.
/// 32 bytes covers typical tab-bar names like "mainbar", "settings_tabs",
/// "files#left_panel". Names longer than this are truncated for
/// persistence purposes only - the hashed Id (which is what drives
/// the live HashMap routing) hashes the full string regardless.
pub const tab_bar_name_cap: usize = 32;

/// Persistent per-tab-bar state. Lives in
/// `UiContext.tab_bar_state`, keyed by the bar's hashed `str_id`.
/// Survives across frames so selection + tab order stay stable.
pub const TabBarState = struct {
    /// Currently-active tab id. Zero = none. First-tab-of-fresh-bar
    /// auto-selects in `beginTabItem` if this stays zero.
    selected_id: Id = 0,
    /// Bar's bottom-y in screen pixels (set during endTabBar).
    bottom_y: f32 = 0,
    /// All tabs known to this bar, in insertion order initially
    /// (drag-reorder may permute when wired).
    tabs: ArrayList(TabState) = .empty,
    /// `frame_count` of the bar's most recent `beginTabBar`.
    /// Future use: bar GC on stale `UiContext.tab_bar_state` entries.
    last_frame_active: u64 = 0,

    // inline buffers identifying the bar by
    // (parent_window_name, str_id). Set once when openTabBar
    // allocates the state, then immutable for the bar's lifetime.
    // Used by `ui_persistence.serialize` to round-trip the bar's
    // selected tab across reloads - persistence needs a stable
    // human-readable identifier, not the hashed `Id`. Sized to
    // cover typical labels; truncation is silent but won't crash
    // (longer names still hash to the same Id, which is what
    // matters for routing).
    str_id_buf: [tab_bar_name_cap]u8 = undefined,
    str_id_len: usize = 0,
    parent_window_name_buf: [tab_bar_name_cap]u8 = undefined,
    parent_window_name_len: usize = 0,

    pub fn deinit(self: *TabBarState, gpa: Allocator) void {
        self.tabs.deinit(gpa);
    }

    /// Linear search by id. Tab counts are small (~5-20 in
    /// practice); a HashMap would be overkill and add indirection.
    pub fn findTab(self: *const TabBarState, id: Id) ?usize {
        for (self.tabs.items, 0..) |t, i| {
            if (t.id == id) {
                return i;
            }
        }
        return null;
    }

    /// Returns the bar's caller-given str_id (truncated to fit if
    /// the original was longer than `tab_bar_name_cap`). Used by
    /// persistence to identify the bar by name rather than by
    /// hashed Id. Empty until openTabBar populates the buffer.
    pub fn strId(self: *const TabBarState) []const u8 {
        return self.str_id_buf[0..self.str_id_len];
    }

    /// Returns the bar's parent window name at allocation time.
    /// See `strId` for the same caveats around truncation.
    pub fn parentWindowName(self: *const TabBarState) []const u8 {
        return self.parent_window_name_buf[0..self.parent_window_name_len];
    }
};

/// Per-frame transient state for the active tab bar. Pushed on
/// `tab_bar_stack` between beginTabBar/endTabBar. The persistent
/// fields live on `TabBarState`; this struct only carries this-frame
/// layout cursor + flags snapshot.
pub const TabBarFrame = struct {
    /// Bar's hashed id (matches the key into `tab_bar_state`).
    id: Id,
    /// Outer rect of the bar's row, screen pixels.
    rect: Rectangle,
    /// Flags as passed to `beginTabBar` this frame.
    flags: TabBarFlags,
    /// X cursors per section. Tabs render left-to-right within
    /// their section; leading section starts at rect.x, middle
    /// after leading, trailing right-anchored.
    leading_cursor_x: f32,
    middle_cursor_x: f32,
    trailing_cursor_x: f32, // grows LEFTWARDS from rect right edge
    /// True if the active tab id changed THIS frame due to a click.
    /// Used to suppress `auto_select_new_tabs` on click-frames.
    selection_changed_this_frame: bool,
    /// True if a tab pushed its id onto id_stack via the
    /// auto-push behavior (cleared by `no_push_id`). Tracked
    /// here so endTabItem knows whether to pop.
    pushed_tab_id_this_item: bool,
};

/// Drag-drop state machine. Lives single-slot on `UiContext`.
/// Phases:
/// - `.idle` - nothing happening; default.
/// - `.pending` - user clicked a draggable widget, mouse is held
///                 down, but hasn't moved past the drag threshold
///                 yet. Promotes to `.active` on first movement
///                 past `DRAG_THRESHOLD` pixels.
/// - `.active` - a drag is in flight. `source_widget_id` says
///                 which widget started it; `payload_*` carry the
///                 typed payload. When the user releases the
///                 mouse, the phase stays `.active` for the rest
///                 of THIS frame so targets can read the payload
///                 in `acceptDragDropPayload`; cleanup back to
///                 `.idle` happens at endFrame.
/// The payload is stored inline in `payload_buf` to keep the
/// drag-drop path heap-free. 256 bytes is enough for typical
/// reference types (struct pointers, ids, small POD structs);
/// anything larger should be passed by id/pointer.
pub const DragDropState = struct {
    pub const Phase = enum { idle, pending, active };
    pub const PAYLOAD_CAP: usize = 256;
    pub const TYPE_NAME_CAP: usize = 96;
    pub const DRAG_THRESHOLD: f32 = 4;

    phase: Phase = .idle,
    /// Widget that originated the drag (last_item_id at the time
    /// `beginDragDropSource` was called).
    source_widget_id: Id = 0,
    /// Where the mouse was when the drag began. Used to detect
    /// movement past the threshold for `.pending →.active`.
    drag_start_pos: Vec2 = .{ 0, 0 },
    /// Comptime type name from `@typeName(T)`, truncated to
    /// `TYPE_NAME_CAP`. Used by `acceptDragDropPayload` ()
    /// to type-match. Empty when no payload is set.
    type_name_buf: [TYPE_NAME_CAP]u8 = undefined,
    type_name_len: usize = 0,
    /// Raw payload bytes. Caller copies a `T` into here via
    /// `setDragDropPayload`.
    payload_buf: [PAYLOAD_CAP]u8 = undefined,
    payload_len: usize = 0,
    /// Widget id of the in-progress drop-target scope, when
    /// `beginDragDropTarget` returned true this frame. Reset to 0
    /// at every beginFrame; reset to 0 at `endDragDropTarget`.
    /// `acceptDragDropPayload` is a no-op when this is 0.
    target_widget_id: Id = 0,

    pub fn typeName(self: *const DragDropState) []const u8 {
        return self.type_name_buf[0..self.type_name_len];
    }
    pub fn payload(self: *const DragDropState) []const u8 {
        return self.payload_buf[0..self.payload_len];
    }
    pub fn clearPayload(self: *DragDropState) void {
        self.type_name_len = 0;
        self.payload_len = 0;
    }
};

/// Saved Style-field state for `pushStyle` / `popStyle`. Each push
/// records the byte offset + size of one Style field plus a copy
/// of its prior bytes; `popStyle` writes those bytes back. Works
/// for any POD field type (all current Style fields qualify:
/// Color, Vec2, f32, i32, ?*const Font). 32 bytes of backup is
/// plenty for any of them.
pub const StyleSnapshot = struct {
    /// Byte offset of the field inside `Style`.
    offset: usize,
    /// Size in bytes of the saved field.
    size: usize,
    /// Original bytes copied at push time. Sized to accommodate
    /// the widest current Style field (?*const Font is 16 bytes on
    /// 64-bit; Vec2 is 8; Color/f32/i32 are 4). 32 is the
    /// safety margin.
    backup: [32]u8 = undefined,
};

/// How a table column's width gets computed. Two modes per
/// decision Q7 - `NoResize` / `NoReorder` / `NoHide` flags from
/// imgui are deferred.
/// Per-column sizing. Set explicitly via `tableSetupColumn`'s
/// `sizing` opt; default null (inherit from the table's
/// `TableOpts.sizing` policy).
///
/// Mirrors imgui's per-column `ImGuiTableColumnFlags_WidthFixed`
/// and `ImGuiTableColumnFlags_WidthStretch` (imgui.h:2200+). zimr
/// folds `WidthAuto` into `.fixed` — a fixed column whose
/// `user_width` is zero behaves the way imgui's `WidthAuto` does:
/// the table sizing policy fills in the width from the column's
/// measured `width_auto_seen`.
pub const TableColumnSizing = enum {
    /// Column grows to fill remaining row width, sharing the
    /// available pixels with other stretch columns according to
    /// their effective weight (see `effectiveStretchWeight`).
    stretch,
    /// Column has an absolute pixel width. When `user_width` is
    /// non-zero, that value is used directly; when zero, the
    /// width is filled in by the table-level sizing policy from
    /// `width_auto_seen` (the previous frame's measured content).
    fixed,
};

/// Per-column sort direction. Imgui's convention: ascending is
/// "smallest first" (alphabetical A→Z, numeric low→high).
pub const TableSortDirection = enum { ascending, descending };

/// Per-column state inside an active table. Capped at
/// `max_table_columns` per the bounded-state convention.
pub const TableColumnState = struct {
    /// Display name passed to `tableSetupColumn`. Length-prefixed
    /// inline buffer so the slice survives past the caller's
    /// `update()` frame - names are read at `tableHeadersRow` time.
    name_buf: [64]u8 = undefined,
    name_len: usize = 0,
    /// Resolved per-column sizing policy. In contrast to the
    /// `?TableColumnSizing` on `TableColumnOpts` (which can be
    /// null = inherit-from-table), this is the concrete value
    /// after inheritance has been applied — never null at the
    /// point any layout code reads it. Default `.stretch` is
    /// only meaningful before `tableSetupColumnImpl` has run; in
    /// practice the inheritance fill in `computeTableColumnLayout`
    /// (or `tableSetupColumnImpl` itself) writes the resolved
    /// value.
    sizing: TableColumnSizing = .stretch,
    /// User-supplied width for `.fixed` columns. Zero means
    /// "no override" — the table's sizing policy decides the
    /// width from `width_auto_seen` (see `effectiveFixedWidth`).
    user_width: f32 = 0,
    /// User-supplied stretch weight. null = "no override" —
    /// the table's sizing policy decides the weight (see
    /// `effectiveStretchWeight`). Non-null means the caller
    /// explicitly set this column's weight, which always wins
    /// over the policy-derived value.
    user_weight: ?f32 = null,
    /// P8.4: maximum content width seen in any cell
    /// of this column during the CURRENT frame, measured in
    /// pixels INCLUDING left+right cell padding (one symmetric
    /// padding's worth on each side). Updated by
    /// `snapshotCellWidth` after each cell submits its content;
    /// persisted to `ctx.table_width_auto_cache` at endTable;
    /// re-loaded from that cache at the NEXT frame's beginTable.
    ///
    /// First-frame caveat: there is no previous-frame data on
    /// the very first frame a table is submitted, so
    /// `computeTableColumnLayout` falls back to
    /// `fallbackColumnWidth(style.font_size)` when this is zero.
    /// One frame of layout lag is intrinsic — zimr is
    /// single-pass and can't know a column's natural width
    /// before its cells render. Stable thereafter as long as
    /// content doesn't change frame-to-frame.
    width_auto_seen: f32 = 0,
    /// Whether sort is suppressed on this column.  Negated form
    /// of the old `sortable: bool = true`.  Default false — sort
    /// cycles normally.  From `TableColumnOpts.no_sort`.
    no_sort: bool = false,
    /// Block ascending direction in the sort cycle.  See
    /// `TableColumnOpts.no_sort_ascending`.
    no_sort_ascending: bool = false,
    /// Block descending direction in the sort cycle.  See
    /// `TableColumnOpts.no_sort_descending`.
    no_sort_descending: bool = false,
    /// Initial sort direction for first-frame seeding.  Consumed
    /// once at `tableSetupColumnImpl` time — the seed result
    /// lives in `ctx.table_sort_state[ts.id]` thereafter and
    /// reads of this field don't affect the live spec.  Kept on
    /// the column for inspection / debug only.  See
    /// `TableColumnOpts.default_sort`.
    default_sort: ?TableSortDirection = null,
    /// Suppress the header text on this column.  Mirrors imgui's
    /// `ImGuiTableColumnFlags_NoHeaderLabel`.  See
    /// `TableColumnOpts.no_header_label`.
    no_header_label: bool = false,
    /// Final computed width in pixels, set at `tableNextRow` based
    /// on outer_width and the sum of fixed/stretch demands.
    width: f32 = 0,
    /// Left edge x in screen pixels, set at `tableNextRow`.
    x: f32 = 0,

    /// Per-cell bg override for the cell at (current_row, this
    /// column). Set by `tableSetCellBgColor` after entering a
    /// column with `tableNextColumn`; consumed (painted to the
    /// splitter's bg channel) when the row finalizes — either at
    /// the NEXT `tableNextRow` or at `endTable`. Reset to null
    /// after painting so the next row's cells start fresh.
    /// P8.1: cell bg paints UNDER cell content because
    /// the splitter merges channel 0 before channel 1; the cell
    /// bg overlays the row bg because cell bgs are emitted AFTER
    /// the row bg in channel 0. Final z-order:
    ///   row_bg → cell_bg → cell_content.
    cell_bg_pending: ?Color = null,

    pub fn name(self: *const TableColumnState) []const u8 {
        return self.name_buf[0..self.name_len];
    }
};

/// One entry in the sort spec list - one of up to three active
/// sort columns.
pub const TableSortSpec = struct {
    /// Index into the table's `tableSetupColumn` order.
    column_index: u32,
    /// Direction this column sorts in.
    direction: TableSortDirection,
};

/// The full sort spec for an active table - up to N=3 columns
/// stacked in priority order (specs[0] is the primary key,
/// specs[1] the secondary tie-breaker, etc.). Per decision Q8.
pub const TableSortSpecs = struct {
    specs: [3]TableSortSpec = undefined,
    len: u8 = 0,

    /// Slice view of the active specs. Caller uses this in
    /// `std.mem.sort` cmp functions.
    pub fn items(self: *const TableSortSpecs) []const TableSortSpec {
        return self.specs[0..self.len];
    }
};

/// Maximum columns per table. Bounded to avoid heap allocation in
/// the hot loop; a tabular dashboard with 32 visible columns is
/// already pushing readability.
pub const max_table_columns: usize = 32;

/// Active-table state. Lives in `UiContext.active_table` between
/// `beginTable` and `endTable`. Single-slot: nested tables are
/// not supported in.
pub const TableState = struct {
    /// Stable id derived from the table's label + parent stack at
    /// `beginTable`. Used for child-state lookups (header click,
    /// sort spec persistence - those come in ).
    id: Id,
    /// Configured column count (from `beginTable`).
    n_columns: u32,
    /// Number of `tableSetupColumn` calls received so far. When
    /// less than `n_columns` at first row, defaults fill in.
    columns_set_up: u32,
    /// Per-column state. Only `[0..n_columns]` is meaningful.
    columns: [max_table_columns]TableColumnState,
    /// Current row's top y (screen pixels).
    row_y: f32,
    /// Current row's accumulated height (max of every cell's
    /// content height submitted so far).
    row_max_h: f32,
    /// Current column index within the row. -1 means "not yet
    /// entered any column this row" (between `tableNextRow` and
    /// the first `tableNextColumn`).
    cur_col: i32,
    /// 0-indexed row number; -1 before any `tableNextRow`.
    cur_row: i32,
    /// Cached options from `beginTable`.
    opts: TableOpts,
    /// Outer table rect in screen pixels, computed at `beginTable`.
    outer_rect: Rectangle,
    /// True once at least one column setup has been recorded, to
    /// short-circuit no-op default fills.
    has_setup: bool,
    /// Vertical scroll offset (pixels). Resolved at `beginTable`
    /// from the persistent `table_scroll_state` map; mutated by
    /// wheel input during data rows; clamped + written back at
    /// `endTable`. Zero means "scrolled to top."
    scroll_y: f32,
    /// Pixel y at which the first data row begins, in screen
    /// coordinates BEFORE applying scroll. Set by
    /// `tableHeadersRow` (or, when no header, by the first
    /// `tableNextRow`). Used at `endTable` to compute the total
    /// data height and clamp `scroll_y`.
    data_origin_y: f32,
    /// True after the data clip-rect has been pushed onto the
    /// draw list. Pushed by the first `tableNextRow`; popped at
    /// `endTable`. Only set when the table actually scrolls
    /// (i.e. `outer_height > 0`).
    clip_pushed: bool,
    /// Per-row background override for the CURRENT row. null
    /// means "use the auto-tint from `opts.row_bg` (if any)."
    /// Reset to null on every `tableNextRow`; set by
    /// `tableSetRowBgColor`.
    row_bg_override: ?Color = null,

    /// 2-channel splitter. Channel 0 receives the
    /// table's row backgrounds + cell borders; channel 1 receives
    /// cell content (widget submissions via `ctx.current_draw_list`).
    /// On `endTable`, the splitter is merged into the window's
    /// draw_list — channel 0's commands first, then channel 1's.
    /// Effect: cell backgrounds always paint UNDER cell content,
    /// regardless of the left-to-right interleave of bg / content
    /// submissions during row iteration. Pre-the bg was
    /// painted AFTER cells were submitted, so it overlapped content;
    /// the workaround was a translucent bg tint. Splitter makes
    /// fully-opaque bg colors render correctly.
    splitter: DrawListSplitter = .{},

    /// Saved value of `ctx.current_draw_list` at `beginTable`.
    /// `endTable` restores it after merging the splitter into the
    /// window's draw_list. The redirection during the table sends
    /// widget content submissions into the splitter's channel 1.
    saved_draw_list: ?*DrawList = null,
};

// ============================================================================
// P2.1: DebugLog ring
// ============================================================================
// Internal event ring buffer that captures the "why did the wrong
// thing get hovered/clicked" trail. Equivalent to imgui's
// `g.DebugLogBuf` (see imgui.cpp `DEBUG_LOG_EVENT_*` macros).
//

// Storage: `BoundedArray(DebugEvent, debug_log_cap)` on `UiContext`.
// When the buffer fills, the oldest event is dropped via
// `orderedRemove(0)` before the new event is appended. No
// allocation; fits inline on the context.
//

// Pushes are best-effort: format-arg truncation past `MAX_MSG`
// bytes is silent; the event still lands with what fit. The
// log never holds allocations — message is an inline byte
// buffer with a length suffix. Process-only state, NOT serialized
// to the persistence layer (debug data, not user data).
//

// Initial wired sites:
//   - openPopup → popup_opened
//   - closeCurrentPopup → popup_closed
//   - beginDragDropSource (when drag starts) → drag_started
//   - acceptDragDropPayloadImpl (on accept) → drop_accepted
//

// Future sites (onward): focus changes, item activations,
// dock requests queued, lint warnings, id collisions.

pub const debug_log_cap: usize = 256;
const debug_log_msg_max: usize = 128;

pub const DebugEventKind = enum {
    focus_changed,
    popup_opened,
    popup_closed,
    item_activated,
    drag_started,
    drop_accepted,
    dock_request_queued,
    id_collision,
    lint_warning,
};

pub const DebugEvent = struct {
    frame: u32,
    kind: DebugEventKind,
    message: [debug_log_msg_max]u8,
    message_len: u8,

    /// Borrow the populated message bytes.
    pub fn messageSlice(self: *const DebugEvent) []const u8 {
        return self.message[0..self.message_len];
    }
};

/// Append a debug event to the ring. Format-args are written into
/// the inline buffer up to `debug_log_msg_max` bytes; longer messages
/// truncate silently. When the ring is full, the oldest event is
/// dropped via `orderedRemove(0)` so the latest cap-many events are
/// always retained.
///
/// Cost when not at cap: one bufPrint + one struct copy. Cost at
/// cap: same plus an O(N) shift inside `orderedRemove(0)`. Both
/// well below the per-frame budget for realistic event rates.
///
/// Errors during format are swallowed: the event still lands with
/// whatever bytes fit before the format ran out of room.
pub fn debugLogPush(
    ctx: *UiContext,
    kind: DebugEventKind,
    comptime fmt: []const u8,
    args: anytype,
) void {
    var ev: DebugEvent = .{
        .frame = @intCast(ctx.frame_count),
        .kind = kind,
        .message = undefined,
        .message_len = 0,
    };
    if (bufPrint(&ev.message, fmt, args)) |written| {
        ev.message_len = @intCast(written.len);
    } else |_| {
        // Truncated. bufPrint may have left a prefix in the buffer
        // even on NoSpaceLeft; record the full buffer as "what got
        // attempted" and move on.
        ev.message_len = debug_log_msg_max;
    }

    if (ctx.debug_log.len == ctx.debug_log.capacity()) {
        _ = ctx.debug_log.orderedRemove(0);
    }
    // BoundedArray.append returns error.Overflow only when at
    // capacity; we just freed a slot, so this can't fail.
    ctx.debug_log.append(ev) catch {};
}

/// Borrow the populated events as a slice in oldest-to-newest order.
/// Returned slice is valid until the next `debugLogPush`.
pub fn debugLogSlice(ctx: *const UiContext) []const DebugEvent {
    return ctx.debug_log.constSlice();
}

// ============================================================================
// P2.2: Frame metrics
// ============================================================================
// `UiContext.metrics: Metrics` — a struct of per-frame counters
// populated by `beginFrame` / `endFrame` and a few collection
// helpers. Equivalent to imgui's `ShowMetricsWindow` data block
// (`imgui_demo.cpp` "Dear ImGui Metrics" panel).
//

// What's tracked:
//   - `frame_count: u32` — wraps once every 828 days
//                                       at 60fps; fine.
//   - `frame_time_ms: f32` — most recent frame's input
//                                       delta_time × 1000.
//   - `frame_time_ms_history: [120]f32` — ring of last 120 samples,
//                                          newest at `history_head`.
//                                          Designed for plotLines.
//   - `history_head: u8` — write index for the next
//                                       sample (0..120 then wraps).
//   - `windows_active: u32` — windows submitted this frame
//                                       (frame_windows.len).
//   - `windows_hovered: u32` — 0 or 1, reflects
//                                       `hovered_window_id != 0`.
//   - `cmd_count: u32` — sum of `draw_list.cmds.len`
//                                       across every drawlist rendered
//                                       this frame. Cheap proxy for
//                                       "how busy was the UI".
//   - `drawlist_count: u32` — number of drawlists rendered
//                                       (background + per-window +
//                                       popups + foreground).
//   - `last_active_widget_id: Id` — sticks (no reset) so the
//                                       user can see WHAT widget got
//                                       clicked even after it goes
//                                       inactive.
//

// What's NOT tracked (deliberately omitted):
//   - Vertex / index counts. zimr submits immediate-mode through
//     rlgl batches; counting vertices means instrumenting rlgl,
//     which is out of scope for a UI-side metrics struct. cmd_count
//     is the practical substitute.
//

// Why no allocator: the metrics struct is plain data (16 bytes for
// scalar fields + 480 bytes for history) and lives inline on the
// context. Reset cycle: per-frame fields zeroed in `beginFrame`,
// counters incremented through the frame, finalized in `endFrame`.
// `history_head` and `frame_count` advance once per frame; the
// rolling history is preserved across resets.

pub const metrics_history_len: usize = 120;

pub const Metrics = struct {
    frame_count: u32 = 0,
    frame_time_ms: f32 = 0,
    frame_time_ms_history: [metrics_history_len]f32 = @splat(0),
    history_head: u8 = 0,
    windows_active: u32 = 0,
    windows_hovered: u32 = 0,
    cmd_count: u32 = 0,
    drawlist_count: u32 = 0,
    last_active_widget_id: Id = 0,
};

/// Sample collector: pushes the per-frame counters that aren't
/// natively tracked elsewhere. Called near the top of `endFrame`
/// after all submissions have closed but before the draw lists
/// render. Reads from `ctx` and writes into `ctx.metrics`.
fn metricsCollectAtEndFrame(ctx: *UiContext) void {
    const m: *Metrics = &ctx.metrics;
    m.frame_count = @intCast(ctx.frame_count);
    m.frame_time_ms = ctx.input.delta_time * 1000.0;
    m.windows_active = @intCast(ctx.frame_windows.len);
    m.windows_hovered = if (ctx.hovered_window_id != 0) 1 else 0;
    if (ctx.active_id != 0) {
        m.last_active_widget_id = ctx.active_id;
    }
    // Ring the frame-time sample. Head advances mod LEN; oldest
    // sample at `(head + 1) % LEN` after the write, newest at
    // `head` AFTER the post-increment below.
    m.frame_time_ms_history[m.history_head] = m.frame_time_ms;
    m.history_head = @intCast((@as(usize, m.history_head) + 1) % metrics_history_len);
}

/// Pre-render counter reset: zeroed before the per-frame replay
/// walks each drawlist and accumulates `cmd_count` / `drawlist_count`.
fn metricsResetRenderCounters(ctx: *UiContext) void {
    ctx.metrics.cmd_count = 0;
    ctx.metrics.drawlist_count = 0;
}

/// Accumulate one drawlist's cmd count. Called for every
/// `DrawList.render` site in `endFrame` (background, per-window,
/// per-popup, foreground, tooltip-block, drag-preview).
fn metricsAccumulateDrawList(ctx: *UiContext, dl: *const DrawList) void {
    ctx.metrics.cmd_count += @intCast(dl.cmds.items.len);
    ctx.metrics.drawlist_count += 1;
}

/// One bar's persisted selection. Identified by the bar's
/// caller-given `str_id`, not its hashed Id, so the storage
/// survives rebuilds. The selected tab is stored by `label`,
/// hashed at restore time under the bar's Id (which itself is
/// the hash of `str_id` under the window's id stack).
pub const PersistedTabBar = struct {
    str_id: []const u8,
    /// Empty string means "no selection persisted" - distinct
    /// from "this bar has no entry at all", which is represented
    /// by the bar's absence from the `tab_bars` array.
    selected_tab_label: []const u8 = "",
};

/// A single window's persistable state. Field set kept minimal
/// today; will grow with `collapsed` (when the field lands on
/// `Window`), `dock_node_id`, and `column_widths`
/// (when tables persist).
/// The window is keyed by `name` - the user-facing title - not by
/// the hashed Id. Hash IDs are stable within a build but the
/// human-meaningful identity is the name, so a refactor that
/// renames a window's title is a clean break in persistence
/// (just as it is in imgui).
pub const PersistedWindow = struct {
    name: []const u8,
    pos: [2]f32,
    size: [2]f32,
    /// Vertical scroll offset. Defaulted so older payloads (or
    /// ones written by demos that don't scroll) parse cleanly.
    scroll_y: f32 = 0,
    /// Horizontal scroll offset. Defaulted so older payloads
    /// without it parse cleanly. Only restored when the live
    /// window has `WindowFlags.horizontal_scrollbar` set —
    /// otherwise scroll_x is force-zeroed each frame by the
    /// X-axis bookkeeping path.
    scroll_x: f32 = 0,
    /// True if the user has manually resized OR explicitly placed
    /// the window - disables auto-fit on the next frame. Defaulted
    /// to false so new-window submissions auto-fit until the user
    /// interacts.
    user_resized: bool = false,
    /// tab-bar selection per bar contained
    /// in this window. Identified by the bar's caller-given
    /// `str_id` so a rename is a clean break (the user submits
    /// `beginTabBar("new_id")`, doesn't find a persisted entry,
    /// falls back to first-tab-auto-select). Defaulted empty
    /// - older payloads parse cleanly.
    tab_bars: []const PersistedTabBar = &.{},
};

/// Pending dock operation. Enqueued by drag-drop interaction;
/// applied by `processRequests` at a well-defined point in the
/// frame so mutations don't interleave with reads during widget
/// submission.
pub const DockRequest = union(enum) {
    /// Add `source_window` as a tab into `target_node` (which
    /// must be a LEAF; the operation is a no-op if it's a SPLIT).
    dock_as_tab: struct { target_node: Id, source_window: Id },

    /// Split `target_node` and place `source_window` in the new
    /// side determined by `dir`. Target must be a LEAF; after the
    /// operation, target's original windows occupy ONE child and
    /// `source_window` occupies the other. No-op if `dir ==.center`.
    dock_as_split: struct { target_node: Id, source_window: Id, dir: Dir },

    /// Remove `source_window` from whatever leaf it's currently in.
    /// If that leaf becomes empty AND has a parent SPLIT, the
    /// parent collapses (the sibling takes the parent's slot).
    /// No-op if the window isn't docked anywhere.
    undock: struct { source_window: Id },

    /// Split a dockspace root (or any leaf) into two children.
    /// Returns the new child IDs via callback (in 5.5b's
    /// `dockBuilderSplitNode` impl). Used by the dockBuilder
    /// API to set up default layouts.
    split: struct { target_node: Id, dir: Dir, ratio: f32 },
};

/// Tagged role for `DockContext.createNode`. Distinct from
/// `DockNode` itself because at creation we only want to specify
/// "leaf or split", not the full node shape.
pub const NodeRole = union(enum) {
    leaf: void,
    split: DockNode.SplitData,
};

/// Pool of nodes + bookkeeping for the docking system. One per
/// UiContext.
pub const DockContext = struct {
    /// All allocated nodes, keyed by `Id`. Values are
    /// heap-allocated so the HashMap can grow without
    /// invalidating outstanding pointers (callers stash `*DockNode`
    /// across frame boundaries).
    nodes: std.AutoHashMapUnmanaged(Id, *DockNode) = .{},

    /// IDs of dockspace roots submitted this frame. Reset each
    /// frame by `beginFrame`; populated by `dockSpace` calls.
    /// Used by drag-drop hit-testing to know which dockspaces are
    /// candidate targets.
    dockspaces_this_frame: ArrayList(Id) = .empty,

    /// Queued operations waiting for `processRequests`. FIFO
    /// requests apply in submission order so a sequence like
    /// "undock A, dock A into B" produces the expected outcome.
    pending_requests: ArrayList(DockRequest) = .empty,

    /// The window currently being dragged for a dock operation,
    /// if any. Drag-drop sets this when a window's title bar
    /// drag exceeds the dock-detach threshold. Used by 5.5e for
    /// rendering the dock-target overlay.
    dragging_window: ?Id = null,

    /// Monotonic ID counter for new nodes. Bumped by
    /// `createNode`. Stable across frames - node IDs persist as
    /// long as the DockContext lives.
    next_node_id: Id = 1,

    pub fn deinit(self: *DockContext, gpa: Allocator) void {
        var it: std.AutoHashMapUnmanaged(Id, *DockNode).Iterator = self.nodes.iterator();
        while (it.next()) |entry| {
            const node: *DockNode = entry.value_ptr.*;
            if (node.leaf) |*leaf| {
                leaf.deinit(gpa);
            }
            gpa.destroy(node);
        }
        self.nodes.deinit(gpa);
        self.dockspaces_this_frame.deinit(gpa);
        self.pending_requests.deinit(gpa);
    }

    /// Resolve `id` to a node pointer. Null when the id doesn't
    /// exist in the pool - caller should always handle this since
    /// IDs may dangle if a node was removed between submission
    /// frames.
    pub fn lookup(self: *const DockContext, id: Id) ?*DockNode {
        return self.nodes.get(id);
    }

    /// Allocate a fresh node with the given role. `role` is
    /// either `.{.leaf =.{} }` (empty leaf) or
    /// `.{.split =.{...} }` (split with two existing child IDs).
    /// Returns the new node's ID.
    pub fn createNode(
        self: *DockContext,
        gpa: Allocator,
        parent_id: ?Id,
        role: NodeRole,
    ) Allocator.Error!Id {
        const id: Id = self.next_node_id;
        self.next_node_id += 1;
        const node = try gpa.create(DockNode);
        errdefer gpa.destroy(node);
        node.* = .{
            .id = id,
            .parent_id = parent_id,
        };
        switch (role) {
            .leaf => node.leaf = .{},
            .split => |s| node.split = s,
        }
        try self.nodes.put(gpa, id, node);
        return id;
    }

    /// Convenience: allocate a fresh empty leaf. Used by
    /// `dockSpace` for the root of a new dockspace.
    pub fn createLeaf(
        self: *DockContext,
        gpa: Allocator,
        parent_id: ?Id,
    ) Allocator.Error!Id {
        return self.createNode(gpa, parent_id, .{ .leaf = {} });
    }

    /// Find the leaf containing `window_id`, if any. Linear scan
    /// over all leaves - fine for the expected node counts (low
    /// dozens at most).
    pub fn findLeafFor(self: *const DockContext, window_id: Id) ?*DockNode {
        var it: std.AutoHashMapUnmanaged(Id, *DockNode).Iterator = self.nodes.iterator();
        while (it.next()) |entry| {
            const node: *DockNode = entry.value_ptr.*;
            if (node.leaf) |*leaf| {
                if (leaf.indexOf(window_id) != null) {
                    return node;
                }
            }
        }
        return null;
    }

    /// Free a node's memory (leaf list + the node itself). Used by
    /// `removeNode` and tree collapse paths. Does NOT update any
    /// parent's child_ids - callers must do that first or the tree
    /// becomes inconsistent.
    fn freeNode(
        self: *DockContext,
        gpa: Allocator,
        node: *DockNode,
    ) void {
        if (node.leaf) |*leaf| {
            leaf.deinit(gpa);
        }
        _ = self.nodes.remove(node.id);
        gpa.destroy(node);
    }
};

/// one dock-tree node's persistable
/// state. Flat representation (no tagged union) so.zon
/// round-tripping is straightforward - `is_split` discriminates
/// the role; the per-role fields are zero/empty for the other.
/// Keyed by `id` - the same u32 the runtime uses for
/// `DockNode.id`. Dockspace ROOT ids are derived by
/// `widgetId(window, str_id)` and stable across runs (assuming
/// the user doesn't rename their dockspace). Split/leaf node
/// ids are auto-allocated sequentially; we restore them
/// verbatim and bump `DockContext.next_node_id` past the max
/// observed id on load.
/// Restore failure (id collisions with already-built tree,
/// dangling child_ids, etc.) falls back to "skip this node and
/// log a warning." Persistence is a hint, never a hard
/// dependency.
pub const PersistedDockNode = struct {
    id: u32,
    /// 0 = no parent (this is a root). Roots are identified
    /// by `flags.is_dockspace` being set as well - bundling
    /// both keeps the schema explicit.
    parent_id: u32 = 0,
    /// `DockNodeFlags` reinterpreted as u16 via `@bitCast`.
    /// Survives schema changes that ADD flag bits (new bits
    /// default to 0); breaks if existing bits are renumbered
    /// (don't do that without a current_version bump).
    flags: u16 = 0,

    // ---- SPLIT role fields (valid when is_split == true)
    is_split: bool = false,
    /// 0 = horizontal, 1 = vertical. Matches `SplitAxis` enum
    /// values; we don't store the enum directly because.zon
    /// would emit the case name as a string and we want
    /// numeric stability.
    split_axis: u8 = 0,
    split_ratio: f32 = 0.5,
    split_child_a: u32 = 0,
    split_child_b: u32 = 0,
    /// `size_ref` is `[2]?f32` at runtime. Use the sentinel
    /// `-1` (negative pixel sizes are invalid anyway) to
    /// encode `null` in the.zon stream.
    split_size_ref_a: f32 = -1,
    split_size_ref_b: f32 = -1,

    // ---- LEAF role fields (valid when is_split == false)
    /// Names of the windows docked into this leaf, in tab
    /// order. Stored by NAME, not by hashed id - names are
    /// human-meaningful and survive rebuilds; ids may not.
    leaf_window_names: []const []const u8 = &.{},
    /// Name of the currently-selected tab. Empty string =
    /// "no selection yet, pick the first one on restore."
    leaf_selected_window_name: []const u8 = "",
};

/// State carried between `beginCombo` and `endCombo` while a combo's
/// dropdown is open. Lives in `UiContext.combo_scope` for the
/// duration of the open-and-submitting phase.
pub const ComboScope = struct {
    /// The combo's widget id - used to verify endCombo pairs.
    id: Id,
    /// Top-left of the dropdown rect (in canvas coords).
    dropdown_origin: Vec2,
    /// Dropdown width - equals the closed-row width.
    dropdown_width: f32,
    /// Saved current_draw_list (the host window's) so endCombo can
    /// restore it after redirecting to foreground_dl.
    saved_dl: ?*DrawList,
    /// Saved owning window so its cursor doesn't get bumped by
    /// widgets submitted into the combo dropdown.
    saved_window: ?*Window,
    /// Inside-the-popup cursor - advanced by every selectable() or
    /// custom widget submitted between begin/end. endCombo uses
    /// the final value to size the dropdown panel.
    cursor_y: f32,
};

/// Per-scope persistent state, survives across frames. Keyed by
/// the scope's window-id-stack-top in `UiContext.ms_storage`. Imgui:
/// `ImGuiMultiSelectState` (`imgui_internal.h`).
pub const MultiSelectState = struct {
    /// The anchor item - see `MultiSelectIO.range_src_item`.
    range_src_item: ?u64 = null,
    /// Selected-state of the anchor. Imgui uses `-1` for "unknown";
    /// we use a separate optional. When the anchor moves
    /// (`range_src_item` changes), this updates to match.
    range_selected: ?bool = null,
    /// Last focused item (for deletion-aware focus restore).
    /// Imgui: `NavIdItem`.
    nav_id_item: ?u64 = null,
    /// Selected-state of `nav_id_item`. Imgui: `NavIdSelected`.
    nav_id_selected: ?bool = null,
    /// `frame_count` from the most recent `beginMultiSelect`. Used
    /// for GC of stale scope state in `ms_storage`.
    last_frame_active: u64 = 0,
    /// `selection_size` passed to the most recent `beginMultiSelect`.
    /// Used by box-select for the "first-selected-item" path.
    /// Imgui: `LastSelectionSize`.
    last_selection_size: u32 = 0,
};

/// Free every entry in `ctx.pending_persistence` and
/// `ctx.pending_tab_bar_selections`. Called from `UiContext.deinit`.
/// After this call, the maps are empty but their backing allocations
/// are left intact for the caller to release via their `deinit(gpa)`.
pub fn clearPending(ctx: *UiContext) void {
    var it: std.StringHashMapUnmanaged(PersistedWindow).Iterator = ctx.pending_persistence.iterator();
    while (it.next()) |entry| {
        ctx.gpa.free(@constCast(entry.key_ptr.*));
    }
    ctx.pending_persistence.clearRetainingCapacity();

    // tab-bar selection staging gets the
    // same treatment. Both key and value are owned by gpa.
    var tb_it: std.StringHashMapUnmanaged([]const u8).Iterator = ctx.pending_tab_bar_selections.iterator();
    while (tb_it.next()) |entry| {
        ctx.gpa.free(@constCast(entry.key_ptr.*));
        ctx.gpa.free(@constCast(entry.value_ptr.*));
    }
    ctx.pending_tab_bar_selections.clearRetainingCapacity();
}

/// release every allocation owned by
/// `ctx.pending_dock_tree` and reset it to empty. Called from
/// `UiContext.deinit`, from `apply()` when re-staging, and from
/// `tryRestoreDockTree` after a successful restore.
pub fn freePendingDockTree(gpa: Allocator, ctx: *UiContext) void {
    for (ctx.pending_dock_tree) |pn| {
        for (pn.leaf_window_names) |n| {
            gpa.free(@constCast(n));
        }
        if (pn.leaf_window_names.len > 0) {
            gpa.free(@constCast(pn.leaf_window_names));
        }
        if (pn.leaf_selected_window_name.len > 0) {
            gpa.free(@constCast(pn.leaf_selected_window_name));
        }
    }
    if (ctx.pending_dock_tree.len > 0) {
        gpa.free(ctx.pending_dock_tree);
    }
    ctx.pending_dock_tree = &.{};
}

fn advanceNav(ctx: *UiContext) void {
    const items: []Id = ctx.frame_nav_items.items[0..ctx.frame_nav_items.len];
    if (items.len == 0) {
        return;
    }
    // Find current nav_id's position; -1 if not in the list.
    var current_idx: i32 = -1;
    for (items, 0..) |item, i| {
        if (item == ctx.nav_id) {
            current_idx = @intCast(i);
            break;
        }
    }
    const items_len_i32: i32 = @intCast(items.len);
    const stepping_back: bool = snapshotShiftDown(&ctx.input);

    if (current_idx < 0) {
        // No current focus - Tab focuses first, Shift+Tab focuses last.
        ctx.nav_id = if (stepping_back) items[items.len - 1] else items[0];
        return;
    }

    // Wrap-around step. We use rem-then-add idiom because Zig's `%`
    // on i32 keeps sign and we want a positive index.
    const step: i32 = if (stepping_back) -1 else 1;
    const raw_next: i32 = current_idx + step;
    const wrapped: i32 = @mod(raw_next, items_len_i32);
    ctx.nav_id = items[@intCast(wrapped)];
}

///.iv: hit-test the mouse against every leaf's drop
/// zones across every dockspace submitted this frame. Returns
/// the FIRST hit found (depth-first, dockspaces in submission
/// order, leaves in tree order). Null when no zone has a
/// non-zero score for `mp`. Called from the title-bar
/// drag-release branch.
/// Sync the window-side dock link from the tree. `processRequests` (dock module)
/// only updates `leaf.window_ids`; it can't touch Window records. This walks every
/// dock node and sets `window.dock_node_id = leaf.id` for each window in each leaf,
/// so runtime drag-to-dock (which goes through processRequests) actually makes the
/// window render docked. Idempotent — the builder/persistence paths already set
/// dock_node_id; re-setting it here is harmless. Undock paths clear dock_node_id,
/// and a window no longer in any leaf is simply not touched (stays floating).
fn syncDockedWindowIds(ctx: *UiContext) void {
    var it: @TypeOf(ctx.dock.nodes.valueIterator()) = ctx.dock.nodes.valueIterator();
    while (it.next()) |node_ptr| {
        const node: *DockNode = node_ptr.*;
        const leaf: DockNode.LeafData = node.leaf orelse continue;
        for (leaf.window_ids.items) |wid| {
            if (ctx.windows.get(wid)) |w| {
                w.dock_node_id = node.id;
            }
        }
    }
}

fn windowIsFloating(w: *Window) bool {
    return w.dock_node_id == null and !w.hosts_dockspace;
}

/// Deferred-render callback queued by `UiContext.endFrame` and
/// fired by `endDrawing`.  Replays every draw list this frame
/// submitted, in the layering order the prior inline-render code
/// used: background → per-window → popups → foreground → tooltip
/// block → drag preview.  Reads the live GlState/Window/shapes/
/// fonts from fields on the UiContext (stamped at `beginFrame`).
/// If any of those is null we silently no-op — the only way that
/// happens is the test path (`beginFrameRaw`), which doesn't run
/// the full lifecycle.
/// Replay this frame's UI draw lists IMMEDIATELY to `gl` (no deferred queue).
/// The GL backend defers via gl.pending_renders (off the framebuffer); the
/// WebGPU backend renders in a single pass, so it calls this directly after the
/// widget calls and before endDrawing. `window`/`shapes_state`/`font_cache` are
/// passed explicitly (beginFrameRaw doesn't stamp frame_window). Mirrors
/// uiContextDeferredRender's replay order (background, windows, popups,
/// foreground, tooltip, drag).
/// Reorder frame_windows so the most-recently-focused FLOATING window renders
/// last (on top). Docked windows and dockspace hosts keep last_focus_frame=0, so
/// they sort first (behind) in submit order; only free-floating windows reorder.
fn bringFocusedFloatingToFront(ctx: *UiContext) void {
    if (ctx.focused_window_id != 0) {
        if (ctx.windows.get(ctx.focused_window_id)) |fw| {
            if (fw.dock_node_id == null and !fw.hosts_dockspace) {
                fw.last_focus_frame = ctx.frame_count;
            }
        }
    }
    const Lt = struct {
        fn lt(_: void, a: *Window, b: *Window) bool {
            const af: bool = windowIsFloating(a);
            const bf: bool = windowIsFloating(b);
            // All docked windows + dockspace hosts sort BEFORE (behind) all
            // floating windows; within a class, by focus recency.
            if (af != bf) {
                return bf;
            }
            return a.last_focus_frame < b.last_focus_frame;
        }
    };
    std.sort.insertion(*Window, ctx.frame_windows.items[0..ctx.frame_windows.len], {}, Lt.lt);
}

fn measureTextS(ctx: *const UiContext, s: []const u8) Vec2 {
    const line_h: f32 = ctx.style.font_size;
    if (s.len == 0) {
        return .{ 0, line_h };
    }
    if (ctx.style.font) |font| {
        const m: Vec2 = text2d.measureWithFont(
            ctx.style.line_spacing,
            font.*,
            s,
            ctx.style.font_size,
            ctx.style.font_spacing,
        );
        // measureEx returns f32 width and height; height = font_size in
        // single-line case, which we want.
        return m;
    }
    // Default-font path - read the live frame font_cache stashed in
    // UiContext at beginFrame. Stale outside begin/endFrame; widgets
    // can only be called between them.
    if (ctx.frame_font_cache) |fc| {
        const w: i32 = text2d.measure(fc, ctx.style.line_spacing, s, @trunc(ctx.style.font_size));
        return .{ @floatFromInt(w), line_h };
    }
    // No frame font_cache (test path or pre-beginFrame). Width
    // is unknowable without metrics; report zero.
    return .{ 0, line_h };
}

/// Filled axis-aligned rectangle. Used by everything (window
/// backgrounds, buttons, frame bgs, separators).
/// Filled rectangle. Routes through the active draw list unless
/// the UI is in eager mode (inside a `pushRenderTexture` scope), in
/// which case it emits straight to rlgl through `shapes.drawRectangleRec`.
/// Either way, one source of truth: rectangles always end up at
/// `shapes.drawRectangleRec`.
fn drawRectFilled(
    ctx: *UiContext,
    r: Rectangle,
    col: ColorU32,
) void {
    const eff: ColorU32 = multiplyAlpha(col, ctx.alpha_mul);
    if (ctx.eager_mode or ctx.current_draw_list == null) {
        const gl: *Gl = ctx.frame_gl orelse return;
        const shapes_state: *const shapes2d.ShapesTextureState = ctx.frame_shapes_state orelse return;
        shapes2d.drawRectangleRec(gl, shapes_state, r, Color.fromWire(eff));
        return;
    }
    ctx.current_draw_list.?.addRectFilled(ctx.frame_arena.allocator(), r, eff);
}

/// Hollow rectangle outline (1px thick). Recorded as a single
/// `rect_outline` command - the four-strip emission happens at
/// replay (so a snapshot of the draw list shows ONE command per
/// outline, not four).
fn drawRect(
    ctx: *UiContext,
    r: Rectangle,
    col: ColorU32,
) void {
    const eff: ColorU32 = multiplyAlpha(col, ctx.alpha_mul);
    if (ctx.eager_mode or ctx.current_draw_list == null) {
        // Mirror the replay path - four 1px strips.
        const c: Color = Color.fromWire(eff);
        const gl: *Gl = ctx.frame_gl orelse return;
        const shapes_state: *const shapes2d.ShapesTextureState = ctx.frame_shapes_state orelse return;
        shapes2d.drawRectangleRec(gl, shapes_state, .{ .x = r.x, .y = r.y, .width = r.width, .height = 1 }, c);
        shapes2d.drawRectangleRec(
            gl,
            shapes_state,
            .{ .x = r.x, .y = r.y + r.height - 1, .width = r.width, .height = 1 },
            c,
        );
        shapes2d.drawRectangleRec(gl, shapes_state, .{ .x = r.x, .y = r.y, .width = 1, .height = r.height }, c);
        shapes2d.drawRectangleRec(
            gl,
            shapes_state,
            .{ .x = r.x + r.width - 1, .y = r.y, .width = 1, .height = r.height },
            c,
        );
        return;
    }
    ctx.current_draw_list.?.addRectOutline(ctx.frame_arena.allocator(), r, eff);
}

fn drawTextAtS(
    ctx: *UiContext,
    pos: Vec2,
    s: []const u8,
    col: ColorU32,
) void {
    if (s.len == 0) {
        return;
    }
    const eff: ColorU32 = multiplyAlpha(col, ctx.alpha_mul);
    if (ctx.eager_mode or ctx.current_draw_list == null) {
        // Eager path - read the live frame substates stashed in
        // UiContext at beginFrame. Stale outside begin/endFrame;
        // widgets can only be called between them.
        const c: Color = Color.fromWire(eff);
        const gl: *Gl = ctx.frame_gl orelse return;
        if (ctx.style.font) |font| {
            text2d.drawWithFont(
                gl,
                ctx.style.line_spacing,
                font.*,
                s,
                pos,
                ctx.style.font_size,
                ctx.style.font_spacing,
                c,
            );
        } else {
            const fc: *const text2d.FontCache = ctx.frame_font_cache orelse return;
            text2d.draw(
                gl,
                fc,
                ctx.style.line_spacing,
                s,
                @trunc(pos[0]),
                @trunc(pos[1]),
                @trunc(ctx.style.font_size),
                c,
            );
        }
        return;
    }
    // Deferred - record. Strings are duplicated into the draw list's
    // arena so they survive past update()'s return.
    ctx.current_draw_list.?.addText(
        ctx.frame_arena.allocator(),
        ctx.style.font,
        s,
        pos,
        ctx.style.font_size,
        ctx.style.font_spacing,
        ctx.style.line_spacing,
        eff,
    );
}

fn renderTooltip(ctx: *UiContext, t: TooltipData) void {
    const text_size: Vec2 = measureTextS(ctx, t.text());
    const padding: Vec2 = ctx.style.frame_padding;
    const rect = Rectangle{
        .x = t.pos[0],
        .y = t.pos[1],
        .width = text_size[0] + 2 * padding[0],
        .height = text_size[1] + 2 * padding[1],
    };
    drawRectFilled(ctx, rect, Color.toWire(ctx.style.window_bg));
    drawRect(ctx, rect, Color.toWire(ctx.style.border));
    drawTextAtS(ctx, .{ rect.x + padding[0], rect.y + padding[1] }, t.text(), Color.toWire(ctx.style.text));
}

/// Compute per-node rects given a root's `pos` and `size`.
/// Walks the tree, splitting space by each SPLIT's ratio and
/// axis. Mutates `node.pos` + `node.size` for every node in
/// the subtree; LEAF rects equal their final on-screen space.
/// Called once per frame per dockspace by the future `dockSpace`
/// widget.
/// Width (or height) of the splitter strip rendered between two
/// siblings of a SPLIT node. The strip is interactive - drag it
/// to change the split ratio. Constant - matches imgui's
/// `g.Style.DockingSeparatorSize` default (4 px). Layout
/// reserves this many pixels per split; the interactive
/// `renderDockSplitters` pass (5.5i, in ui.zig) draws + handles
/// drag in this gap.
pub const splitter_size: f32 = 4.0;

/// work out child sizes for a SPLIT node
/// given the available extent. Returns `[w_a, w_b]` (or `[h_a,
/// h_b]` for vertical splits) summing to `avail`.
/// Resolution order:
///   1. If exactly one child has a non-null `size_ref`, that
///      child is locked at `clamp(size_ref, MIN, avail-MIN)` and
///      the other child absorbs the remainder. This is the
///      "fixed-width left panel, flexible center" UX - the
///      hallmark feature this turn unlocks.
///   2. If both `size_ref` slots are null OR both are non-null
///      (over-constrained), fall back to `ratio`.
/// Imgui equivalent: per-node `SizeRef` field consulted by
/// `DockNodeTreeUpdatePosSize` (imgui.cpp:20322-20338). Our
/// model differs in two ways:
///   (a) `size_ref` lives on the SPLIT (one-per-pair) rather
///       than on each NODE - simpler, fewer places state can
///       drift, easier to serialize.
///   (b) No `WantLockSizeOnce` transient bool - splitter drag
///       writes directly to size_ref or ratio, no two-phase
///       protocol.
/// Compute the two child sizes for a SPLIT node given the available
/// pixels along the split axis. Returns `[size_of_child_a, size_of_child_b]`.
///
/// Rules:
///   - Both children locked OR both free → fall back to `split.ratio`.
///   - Exactly one locked → that child takes its locked size, the
///     other takes the remainder.
///   - Either child clamps to MIN_PANEL (24px) so a dragged splitter
///     can't shrink a pane to a sliver.
///
/// Used both by the runtime dock-rendering path (twice) and by the
/// docking persistence/test paths. Pre-turn-414 there was a `Public`
/// wrapper alias here because `DockNode.SplitData` lived in a
/// separate file; that wrapper is gone now that the dock layer is
/// inlined.
pub fn computeSplitChildSizes(split: DockNode.SplitData, avail: f32) [2]f32 {
    const MIN_PANEL: f32 = 24.0;
    const a_lock: ?f32 = split.size_ref[0];
    const b_lock: ?f32 = split.size_ref[1];

    // Both locked OR both free → ratio fallback.
    if ((a_lock != null) == (b_lock != null)) {
        const w_a: f32 = avail * split.ratio;
        return .{ w_a, avail - w_a };
    }

    if (a_lock) |px| {
        const w_a: f32 = clamp(px, MIN_PANEL, @max(MIN_PANEL, avail - MIN_PANEL));
        return .{ w_a, avail - w_a };
    } else {
        // b is locked
        const w_b: f32 = clamp(b_lock.?, MIN_PANEL, @max(MIN_PANEL, avail - MIN_PANEL));
        return .{ avail - w_b, w_b };
    }
}

pub fn layoutSubtree(
    ctx: *const DockContext,
    root_id: Id,
    root_pos: Vec2,
    root_size: Vec2,
) void {
    const root: *DockNode = ctx.lookup(root_id) orelse return;
    root.pos = root_pos;
    root.size = root_size;

    if (root.split) |split| {
        const a: *DockNode = ctx.lookup(split.child_ids[0]) orelse return;
        const b: *DockNode = ctx.lookup(split.child_ids[1]) orelse return;
        switch (split.axis) {
            .horizontal => {
                const avail: f32 = @max(0, root_size[0] - splitter_size);
                const sizes: [2]f32 = computeSplitChildSizes(split, avail);
                layoutSubtree(ctx, a.id, root_pos, .{ sizes[0], root_size[1] });
                layoutSubtree(
                    ctx,
                    b.id,
                    .{ root_pos[0] + sizes[0] + splitter_size, root_pos[1] },
                    .{ sizes[1], root_size[1] },
                );
            },
            .vertical => {
                const avail: f32 = @max(0, root_size[1] - splitter_size);
                const sizes: [2]f32 = computeSplitChildSizes(split, avail);
                layoutSubtree(ctx, a.id, root_pos, .{ root_size[0], sizes[0] });
                layoutSubtree(
                    ctx,
                    b.id,
                    .{ root_pos[0], root_pos[1] + sizes[0] + splitter_size },
                    .{ root_size[0], sizes[1] },
                );
            },
        }
    }
    // Leaves get their rect directly from the recursion above;
    // nothing else to compute.
}

/// The seam between a split node's two children, sized
/// `splitter_size` along the split axis and the full
/// perpendicular extent of the split. Returns a zero-sized
/// rectangle for leaf nodes (caller should already have
/// branched on `node.split`).
/// Position respects size_ref: a child locked at N pixels makes
/// the seam land at N pixels in, not at the ratio-derived
/// position. Otherwise the seam appears to drift when the user
/// resizes the dockspace.
fn dockSplitterSeamRect(node: *const DockNode) Rectangle {
    const split: DockNode.SplitData = node.split orelse return .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    const avail: f32 = switch (split.axis) {
        .horizontal => @max(0, node.size[0] - splitter_size),
        .vertical => @max(0, node.size[1] - splitter_size),
    };
    const sizes: [2]f32 = computeSplitChildSizes(split, avail);
    const a_size: f32 = sizes[0];
    return switch (split.axis) {
        .horizontal => .{
            .x = node.pos[0] + a_size,
            .y = node.pos[1],
            .width = splitter_size,
            .height = node.size[1],
        },
        .vertical => .{
            .x = node.pos[0],
            .y = node.pos[1] + a_size,
            .width = node.size[0],
            .height = splitter_size,
        },
    };
}

fn pointInRect(p: Vec2, r: Rectangle) bool {
    return p[0] >= r.x and p[0] < r.x + r.width and
        p[1] >= r.y and p[1] < r.y + r.height;
}

fn renderDockSplittersImpl(ctx: *UiContext, node_id: Id) bool {
    const node: *DockNode = ctx.dock.lookup(node_id) orelse return false;
    if (node.split) |split| {
        // Recurse into children first. Visually, parent seam
        // draws on top of children, so we paint child seams
        // first (further from root) then ours.
        var changed: bool = false;
        if (renderDockSplittersImpl(ctx, split.child_ids[0])) {
            changed = true;
        }
        if (renderDockSplittersImpl(ctx, split.child_ids[1])) {
            changed = true;
        }

        const seam_rect: Rectangle = dockSplitterSeamRect(node);

        // Widget id: derive from node id with a salt so we can't
        // collide with normal widgetId hashes for the same u32.
        const id: Id = hashStr(node.id, "__dock_splitter");

        const hovered: bool = pointInRect(ctx.input.mouse_pos, seam_rect);
        if (hovered) {
            ctx.hovered_id = id;
        }

        const interactive: bool = !node.flags.no_resize;

        // Press: claim active_id.
        if (interactive and hovered and ctx.input.mouse_left_clicked and ctx.active_id == 0) {
            ctx.active_id = id;
            ctx.active_id_just_activated = true;
        }

        const held: bool = (ctx.active_id == id and ctx.input.mouse_left_down);

        if (held and interactive) {
            // Compute new ratio from mouse position relative to
            // the available (non-seam) extent. Convert mouse
            // position in the split's axis to a fraction.
            const avail: f32 = switch (split.axis) {
                .horizontal => @max(1, node.size[0] - splitter_size),
                .vertical => @max(1, node.size[1] - splitter_size),
            };
            const mouse_in_axis: f32 = switch (split.axis) {
                .horizontal => ctx.input.mouse_pos[0] - node.pos[0],
                .vertical => ctx.input.mouse_pos[1] - node.pos[1],
            };
            // Mouse landed at the LEFT edge of the seam (seam
            // origin) when ratio was 0. Use seam center as the
            // pivot for symmetric feel.
            const target_w_a: f32 = clamp(
                mouse_in_axis - splitter_size * 0.5,
                avail * 0.05,
                avail * 0.95,
            );

            // if a size_ref lock is active,
            // drag updates the locked side's px rather than the
            // ratio - this preserves the fixed-width-panel UX
            // (drag widens/narrows the locked panel, keeps it
            // locked at the new size). When no lock is set, fall
            // back to ratio mutation (5.5i behavior).
            if (split.size_ref[0] != null) {
                // a is locked → mutate its px to the new width.
                if (split.size_ref[0].? != target_w_a) {
                    node.split.?.size_ref[0] = target_w_a;
                    node.generation +%= 1;
                    changed = true;
                }
            } else if (split.size_ref[1] != null) {
                // b is locked → b's px is `avail - target_w_a`.
                const new_b_px: f32 = avail - target_w_a;
                if (split.size_ref[1].? != new_b_px) {
                    node.split.?.size_ref[1] = new_b_px;
                    node.generation +%= 1;
                    changed = true;
                }
            } else {
                const new_ratio: f32 = target_w_a / avail;
                if (new_ratio != split.ratio) {
                    node.split.?.ratio = new_ratio;
                    node.generation +%= 1;
                    changed = true;
                }
            }
        }

        // Release: clear active_id.
        if (ctx.active_id == id and ctx.input.mouse_left_released) {
            ctx.active_id = 0;
        }

        // Draw the seam. Color by state. Idle seam is a
        // visible (but not loud) strip so the user can SEE where
        // to grab; hover brightens it; held uses the active
        // button color for maximum feedback. Style tokens for a
        // dedicated splitter color don't exist yet; filed for
        // future style cleanup (matches imgui's pending TODO).
        const fill_color: Color = if (held)
            ctx.style.button_active
        else if (hovered and interactive)
            ctx.style.button_hovered
        else
            .{ .r = 90, .g = 100, .b = 130, .a = 230 };
        drawRectFilled(ctx, seam_rect, Color.toWire(fill_color));

        return changed;
    }
    return false;
}

/// walk the subtree and handle interaction
/// + rendering for every split node's seam. Returns true if a
/// drag changed any `split.ratio` this call - the caller re-runs
/// layout to apply the change THIS frame.
/// The seam rect is the `splitter_size`-wide gap reserved by
/// `layoutSubtree` between a split node's two children. We draw
/// + hit-test it directly; no widget id is "registered" on a
/// window because dockspace seams don't live in any window's
/// content area.
/// The widget id is derived from the split node id via
/// `hashU32` so it can't collide with normal `widgetId` hashes
/// (which thread through `current_window`'s id stack).
/// Quick-hit UX features beyond imgui's basic drag:
///   - Double-click resets ratio to 0.5 (50/50). imgui doesn't
///     do this; common pattern from VS Code / Photoshop.
///   - `no_resize` flag on the split node disables interaction
///     (but the seam still renders so the visual layout reads
///     correctly).
/// Runs inside `flushDockTabsToForeground`, so renders into
/// whatever draw list is current there (caller switches to
/// foreground_dl AFTER this returns - splitters draw into the
/// per-window dl by default, layered below the tab bars; ideal).
fn renderDockSplitters(ctx: *UiContext, root_id: Id) bool {
    return renderDockSplittersImpl(ctx, root_id);
}

/// walk the dockspace subtree rooted at
/// `root_id` and emit a clickable tab bar across the top edge of
/// every leaf. Tabs reflect the leaf's `window_ids` in order;
/// the active tab matches `leaf.selected_window_id`.
/// Click handling: if the mouse is over a tab AND the user pressed
/// left this frame, set `selected_window_id` to that tab's window.
/// The selected window's content shows from THIS frame's submission
/// because findOrCreateWindow read pos/size from the leaf earlier,
/// and openWindow's docked-and-selected check sees the just-updated
/// selection. Tab clicks on a non-selected window therefore take
/// effect on the SAME frame - no one-frame stutter.
/// Tab labels: looked up via `ctx.windows.get(wid).name()`. Windows
/// that haven't been submitted yet (dockBuilder ran before any
/// `window()` call) get a fallback hex-id label so the bar is at
/// least visible.
/// Drains `ctx.dock.dockspaces_this_frame` and emits tabs into
/// `ctx.foreground_dl`.  Called from `endFrame` in normal
/// run-mode; called from the screenshot section's `renderToBytes`
/// in host-test mode so single-frame PNG renders see the tabs.
pub fn flushDockTabsToForeground(ctx: *UiContext) void {
    for (ctx.dock.dockspaces_this_frame.items) |dockspace_id| {
        // Re-run layout with the dockspace's CURRENT root pos/size.
        // The user's `dockBuilder*` calls between `dockSpace()` and
        // endFrame may have mutated the tree (splits, dock-as-tab)
        // - re-walking the tree here ensures every leaf has a
        // fresh rect for the tab-bar rendering pass. Pos/size of
        // the root were captured by `dockSpaceImpl` and persist on
        // the root node across the frame.
        const root: *DockNode = ctx.dock.lookup(dockspace_id) orelse continue;
        layoutSubtree(&ctx.dock, dockspace_id, root.pos, root.size);

        // process splitter drags BEFORE
        // drawing tabs. A drag updates `split.ratio` in place;
        // we re-run layout once more so the visual reflects the
        // new ratio THIS frame (otherwise the splitter visibly
        // lags one frame behind the cursor - annoying).
        const splitter_changed: bool = renderDockSplitters(ctx, dockspace_id);
        if (splitter_changed) {
            layoutSubtree(&ctx.dock, dockspace_id, root.pos, root.size);
        }

        const saved: ?*DrawList = ctx.current_draw_list;
        ctx.current_draw_list = &ctx.dock_tabs_dl;
        renderDockLeafTabBars(ctx, dockspace_id);

        //.iii: when a window is being dragged,
        // overlay each leaf with a 5-zone preview (center + 4 edges)
        // so the user can see where the released drag will dock.
        // Renders into foreground_dl above the tab bars. The
        // hit-test + DockRequest enqueue belongs to 5.5d.iv
        // today the overlay is purely visual feedback.
        if (ctx.dock.dragging_window != null) {
            renderDockTargetOverlay(ctx, dockspace_id);
        }

        ctx.current_draw_list = saved;
    }
}

/// The "global" state, made explicit. Lives in `App`, set up once,
/// borrowed each frame. Owns the persistent window store.
pub const UiContext = struct {
    gpa: Allocator,

    /// Per-frame arena for DrawList variable-length payloads - text
    /// bytes, polyline points, future path data, and the DrawList
    /// command lists themselves. Reset (capacity-retaining) at
    /// endFrame after all draw lists have rendered. Slices recorded
    /// into cmds remain valid for one full frame.
    /// Initialized in `UiContext.init` from `gpa`. The user passes
    /// only gpa - ui creates and manages this arena internally, which
    /// is an exception to the broader zimr rule that allocating
    /// functions take scratch/gpa/both per call.
    frame_arena: FrameArena,

    // Whether the wasm runtime should swallow keyboard / mouse
    // events instead of forwarding them to the host page is
    // computed on demand by `Ui.wantCaptureKeyboard` /
    // `Ui.wantCaptureMouse` (active_id != 0 / mouse-over a frame
    // window), so no static flag lives on the context.

    /// Persistent open/closed state for `treeNode` widgets, keyed
    /// by the node's hashed ID. Survives across frames so trees
    /// stay in the state the user left them. Default: missing
    /// entry → closed. Memory: lives in `gpa`.
    tree_open_state: std.AutoHashMapUnmanaged(Id, bool) = .{},

    /// Persistent window store, keyed by hashed title.
    windows: std.AutoArrayHashMapUnmanaged(Id, *Window) = .{},

    // ---- persistence
    // `pending_persistence` holds PersistedWindow entries loaded from
    // a previous session's `.zon` payload before any `beginFrame` has
    // run. When `findOrCreateWindow` first sees a title, it consumes
    // the matching pending entry (if any) and grafts pos/size/scroll
    // onto the new Window before the per-frame layout runs. After
    // consumption the entry is removed; entries that never get
    // consumed (titles no longer in the program) are dropped on
    // `UiContext.deinit`.
    // `persistence_key` is the opt-in identifier used by the
    // storage layer (localStorage key, file path on host). When
    // null, no save/load happens - pure in-memory mode, same as
    // every existing demo today. Set by callers before the first
    // beginFrame.
    pending_persistence: std.StringHashMapUnmanaged(PersistedWindow) = .{},
    persistence_key: ?[]const u8 = null,

    /// per-bar selection staging. Keyed by
    /// the compound string `"<window_name>\x00<str_id>"` (NUL
    /// separator is fine for our use - both window names and
    /// str_ids exclude NUL). Value is the persisted selected-tab
    /// label. Populated by `ui_persistence.apply`; consumed by
    /// `openTabBar` on the FIRST submission of each bar. Unconsumed
    /// entries (bars no longer in the program) are released on
    /// `UiContext.deinit`.
    pending_tab_bar_selections: std.StringHashMapUnmanaged([]const u8) = .{},

    /// docking system. Pure-data tree of
    /// `DockNode` pooled by `Id` plus per-frame bookkeeping
    /// (active dockspaces, pending operations). Defaulted to
    /// empty - apps that don't use docking pay zero overhead
    /// (the maps stay empty, the per-frame lists are no-op'd).
    dock: DockContext = .{},

    /// persisted dock tree awaiting
    /// restore. Populated by `ui_persistence.apply` from a
    /// loaded payload; drained by `dockSpace()` on the first
    /// call after load. Memory is owned by `gpa` - strings
    /// inside (window names) are duped at apply-time so
    /// they survive past the zon-parse free. Cleared on
    /// successful drain or on `UiContext.deinit`.
    pending_dock_tree: []PersistedDockNode = &.{},

    /// Active style - mutated directly or via push/pop in later phases.
    style: Style = Style.dark_default,

    // ---- Phase A1: clipboard read-cache
    // Browser clipboard reads are async (Promise-based). This
    // 4KB fixed buffer caches the most recent successful read so
    // `Ui.getClipboardText()` can be sync from the user's
    // perspective. Populated by `runtime.pollClipboardText` when
    // a fetch completes; until then `clipboard_cache_len` is 0
    // and the getter returns "".
    // Sized at 4096 bytes - covers typical text-clipboard
    // contents (URLs, command lines, code snippets). Longer
    // contents truncate cleanly. Pure-data; no allocation.
    clipboard_cache: [4096]u8 = undefined,
    clipboard_cache_len: usize = 0,

    // ---- DebugLog ring
    // BoundedArray<DebugEvent, 256> — fixed-capacity ring backed by
    // an inline array. Overflow is handled in `debugLogPush` by
    // dropping the oldest event before appending. No allocation,
    // no serialization. See `DebugEvent` and `debugLogPush`.
    debug_log: BoundedArray(DebugEvent, debug_log_cap) = .{},

    // ---- Frame metrics
    // Plain inline struct (~520B). Reset cadence:
    //   - `metricsResetRenderCounters` zeroes cmd_count + drawlist_count
    //     just before the per-frame render loop in `endFrame`.
    //   - `metricsCollectAtEndFrame` populates the rest near the top
    //     of `endFrame`.
    //   - `metricsAccumulateDrawList` adds to cmd_count + drawlist_count
    //     once per `DrawList.render` call (5 fixed sites in `endFrame`
    //     plus tooltip-block + drag-preview if active).
    metrics: Metrics = .{},

    // ---- P3: Long-press → right-click synthesis
    // Single-touch long-press tracker. Inspected + mutated by
    // `applyLongPressSynthesis` once per `beginFrame`. Default-zero
    // state means "no touch being tracked"; gets initialised on the
    // first frame a single finger is observed down.
    long_press: LongPressTracker = .{},

    /// Input snapshot for the in-flight frame. Stamped by
    /// `beginFrame`.
    input: InputSnapshot = .{},

    /// Live `*InputState` for the in-flight frame. Most input reads
    /// go through `input` (the snapshot) for headless-testability, but
    /// a few imgui-parity calls - `Ui.resetMouseDragDelta`,
    /// `Ui.setMouseCursor` - need to MUTATE input state, and a few
    /// drag helpers need fields the snapshot doesn't carry
    /// (per-button press anchor). Stamped by `beginFrame`; null
    /// outside a frame.
    input_state: ?*runtime.input.InputState = null,

    /// Per-frame gesture detector, ticked once in `beginFrameRaw` from
    /// `input_state` + the monotonic UI clock. Lets widgets read pinch /
    /// drag / swipe via `Ui.pinch()` etc. without each rolling their own
    /// touch bookkeeping — the same detector the standalone demos use.
    gestures: runtime.gestures.GesturesState = .{},

    /// The window currently being submitted to (between
    /// `window(...)` and `WindowHandle.close()`). Null outside.
    current_window: ?*Window = null,

    /// FIFO of active window pointers - supports nested
    /// `window(...)` calls (rare but allowed).
    window_stack: BoundedStack(*Window, 8) = .{},

    /// ID stack for disambiguation. Push a parent ID; subsequent
    /// `getId(label)` calls hash with the top of stack as seed.
    id_stack: BoundedStack(Id, 32) = .{},

    /// The widget the user is currently interacting with (e.g.
    /// holding a button down). Zero = none.
    active_id: Id = 0,
    /// Set the frame `active_id` was set non-zero - used for one-
    /// shot logic like "did the user just start a drag?"
    active_id_just_activated: bool = false,
    /// When true, the NEXT focusable widget submitted this frame
    /// claims keyboard focus (`active_id` set to that widget's id).
    /// Set by `ui.setKeyboardFocusHere()`; cleared either by the
    /// widget that consumed it OR at the end of the frame. Today
    /// only `inputText` honors this - every other focusable widget
    /// could opt in by checking the flag in the same idiom.
    next_widget_take_focus: bool = false,
    /// Mouse-X at the moment the active widget was first clicked.
    /// Used by `drag` to compute cumulative drag distance.
    active_id_press_x: f32 = 0,
    /// Initial value (as f64) at the moment the active widget was
    /// first clicked. Used by `drag` to compute new value =
    /// initial + delta * speed.
    active_id_press_value: f64 = 0,
    /// frame time (seconds since context init)
    /// when active widget was first clicked. Used by
    /// `ItemFlags.button_repeat` to compute initial-delay timing.
    active_id_press_time: f32 = 0,
    ///.ii: mouse position at the moment the
    /// active widget was first clicked, in SCREEN (canvas) pixels.
    /// `active_id_press_x` / `_value` stash the offset WITHIN the
    /// dragged target (window-local), useful for "stay anchored to
    /// the press point" continue-drag behavior; this field is the
    /// raw screen press point, useful for cumulative-motion
    /// thresholds (e.g. dock-drag detach: "did the mouse move more
    /// than 8px from where it was pressed?"). Set on every
    /// active_id_just_activated frame; never cleared (the next
    /// activation overwrites).
    active_id_press_screen: Vec2 = .{ 0, 0 },
    /// frame time of the most recent
    /// auto-repeat tick for the active button-repeat widget.
    active_id_last_repeat_time: f32 = 0,
    /// cumulative frame time, accumulated
    /// from `frame.time.delta_time` at every beginFrame. Used as the
    /// monotonic clock for button-repeat cadence (and future
    /// hover-delay). Independent of wall clock - only advances
    /// when the ui ticks, so a tab-backgrounded app doesn't
    /// emit a flood of repeats when re-foregrounded.
    frame_time_seconds: f32 = 0,
    /// The widget the mouse is over. Set during widget submission
    /// in the order of submission, so the LAST widget under the
    /// mouse wins (matches ImGui's behavior).
    hovered_id: Id = 0,
    /// Hovered ID from the previous frame - used for hover-stable
    /// transitions (e.g. "just-hovered" detection).
    hovered_id_prev_frame: Id = 0,
    /// Active ID from the previous frame. Used to compute per-widget
    /// activated/deactivated transitions (e.g. for `isItemActivated`).
    /// Snapshotted in `endFrame`; the helper `markItem` consults it.
    active_id_prev_frame: Id = 0,
    /// Was the previous-frame's active widget edited at any point
    /// during its activation? Sticky during the activation; resets
    /// when the widget is deactivated. Drives
    /// `isItemDeactivatedAfterEdit`.
    active_id_was_edited: bool = false,

    // ---- keyboard nav state ---------------------------------
    /// The widget that currently holds keyboard focus. Independent
    /// of `active_id` (which tracks mouse-drag captures). Zero =
    /// nothing focused; widgets compare their id to this value to
    /// decide whether to (a) draw a focus border, (b) treat Enter as
    /// a synthesized click. Mutated at beginFrame from Tab/Shift+Tab
    /// based on the PREVIOUS frame's submitted nav items.
    nav_id: Id = 0,
    /// Submitted-this-frame focusable widget IDs in submission order.
    /// Built up via `markItemNavigable` calls during widget
    /// submission, consulted at the START of the NEXT frame's
    /// beginFrame to advance `nav_id` on Tab. Cleared each frame.
    /// Cap of 64 covers typical workloads - a screen with more than
    /// 64 focusable widgets at once is a UX problem regardless.
    frame_nav_items: BoundedStack(Id, 64) = .{},

    /// Auto-position counter for windows that didn't specify a
    /// position. Increments each frame to spread default windows.
    next_default_pos: Vec2 = .{ 60, 60 },

    /// Frame counter for GC + animation timing.
    frame_count: u64 = 0,

    // ---- setNextWindow* state
    // Per-frame "next window" overrides; consumed by the next
    // `openWindow` call and reset. Mirrors ImGui's `SetNextWindowPos`
    // / `SetNextWindowSize` family.

    /// Position to apply to the next window opened this frame.
    /// Cleared after `openWindow` consumes it.
    next_window_pos: ?Vec2 = null,
    /// `true` ⇒ apply on every frame; `false` ⇒ apply only when the
    /// window is first created (matches ImGui's `ImGuiCond_Once`).
    next_window_pos_always: bool = true,
    /// Size override (analogous to next_window_pos).
    next_window_size: ?Vec2 = null,
    next_window_size_always: bool = true,
    /// Minimum-size constraint for the next window, applied at
    /// open time and every subsequent frame the window exists.
    /// Stored as `(min, max)` pair so a single
    /// `setNextWindowSizeConstraints` call covers both bounds.
    /// `null` = no min constraint. Imgui: `SetNextWindowSizeConstraints`
    /// (`imgui.cpp:8664-8671`). Imgui also supports a custom
    /// callback for non-rectangular constraints (aspect ratio
    /// etc.) - deferred follow-up.
    next_window_size_min: ?Vec2 = null,
    /// Max-size constraint, same shape as min. `null` = no max.
    /// FLT_MAX semantically equivalent; we use null because
    /// optional is cheap.
    next_window_size_max: ?Vec2 = null,
    /// Background alpha multiplier for the next window - 0 = fully
    /// transparent background, 1 = opaque (default). Cleared after
    /// the window is opened.
    next_window_bg_alpha: ?f32 = null,
    /// Force the next window to top-of-z-order (raises it above
    /// other windows in the submission order list).
    next_window_focus: bool = false,
    /// override the next tree node's open
    /// state. Consumed by the first `treeNode` after the call.
    /// `null` = no override (use stored state or `default_open`).
    /// Imgui: `SetNextItemOpen` (`g.NextItemData.OpenVal` /
    /// `OpenCondVal`).
    next_item_open: ?bool = null,
    /// set to true within `treeNodeExImpl` when the
    /// node's open state flipped this frame. Surfaced via
    /// `isItemToggledOpen` (matches imgui's
    /// `ImGuiItemStatusFlags_ToggledOpen`). Reset at every
    /// widget submission.
    item_just_toggled: bool = false,
    /// Window-id of the currently-focused window (top of z-order in
    /// `frame_windows`). Updated when a window is clicked or
    /// `setNextWindowFocus` raises one.
    focused_window_id: Id = 0,
    /// Window-id under the mouse this frame (set during widget
    /// submission as windows are encountered). Used by
    /// `isWindowHovered` and to gate widget hit-tests so a click
    /// hitting two stacked windows only activates the top one.
    hovered_window_id: Id = 0,

    /// Per-frame flag set true once a window (or child window)
    /// consumes the mouse wheel. Inner children's `endChildImpl`
    /// runs before outer's (due to nesting order), so the deepest
    /// scope under the cursor gets first dibs. Reset to false at
    /// each `beginFrameRaw`; checked + set by every scroll-aware
    /// scope (`closeWindow`, `endChildImpl`, `closeListBox`).
    /// Matches imgui's `WantCaptureMouse`-like cascade - the
    /// deepest hovered scope wins, outer scopes see "wheel
    /// already handled" and don't double-apply.
    mouse_wheel_consumed: bool = false,
    /// P5.5b: horizontal counterpart of
    /// `mouse_wheel_consumed`. Reset at each `beginFrameRaw`;
    /// checked + set by horizontal-scroll-aware scopes. Kept
    /// separate from the Y consumed flag so a window can scroll Y
    /// from a hovered child while still allowing X scroll on the
    /// outer window (axes consume independently).
    mouse_wheel_x_consumed: bool = false,

    /// Pending tooltip submission. Last call wins (matches ImGui
    /// semantics). Rendered at endFrame on top of everything else.
    pending_tooltip: ?TooltipData = null,

    /// Active input-text edit state. Valid only while a text input
    /// has focus (active_id matches the input's ID). Reset to
    /// defaults when focus is gained on a different inputText (the
    /// cursor jumps to wherever the new one was last left or to the
    /// end of buffer if first focus). holds just cursor +
    /// blink; selection ranges + scroll come later.
    input_text_state: InputTextState = .{},

    /// Id of the combo widget currently in its "open" (dropdown
    /// expanded) state, or 0 for none. Single-slot - only one combo
    /// is open at a time. Persisted across frames. Opening combo B
    /// while combo A is open implicitly closes A.
    combo_open_id: Id = 0,

    /// Active `beginCombo` scope. Non-null between a `beginCombo`
    /// call that returned true and the matching `endCombo`. Holds
    /// the dropdown's geometry + saved cursor state so widgets
    /// submitted inside the combo (typically `selectable` calls)
    /// route into the dropdown rect instead of advancing the
    /// host window's cursor.
    combo_scope: ?ComboScope = null,

    // ---- Deferred draw lists (proper z-order)
    // Widgets append commands to a draw list during submission. At
    // endFrame, lists are replayed: window draw lists first (in order
    // they were submitted this frame), foreground draw list last (so
    // popups + tooltips overlay everything).

    /// Currently active draw list. Widget helpers
    /// (drawRectFilled etc.) append commands here unless `eager_mode`
    /// is true. Set by openWindow/closeWindow to the active window's
    /// draw list, or temporarily redirected for popups/tooltips.
    /// Null means no widget submission is currently in scope.
    current_draw_list: ?*DrawList = null,

    /// Foreground draw list - popups, tooltips, anything that should
    /// overlay all windows. Lives on the context (not a window) since
    /// it's not bound to any specific window's z-order. Cleared each
    /// frame. Allocates through `frame_arena` like every other
    /// DrawList. Exposed via `Ui.getForegroundDrawList()`.
    foreground_dl: DrawList = .{},
    /// Dock leaf tab bars render here — ABOVE docked content but BELOW floating
    /// windows, so floating headers sit on top of dock tabs.
    dock_tabs_dl: DrawList = .{},

    /// Background draw list - renders BEFORE any window, so callers
    /// can paint a backdrop / grid / watermark that lives under all
    /// UI. Same shape as `foreground_dl` (frame-arena allocated,
    /// cleared every frame). Exposed via `Ui.getBackgroundDrawList()`.
    /// Most apps leave this empty; useful for tools that want a
    /// consistent backdrop behind their windows (a CAD app's grid,
    /// a level editor's brush preview, etc.).
    background_dl: DrawList = .{},

    /// Block-tooltip window - lazily allocated on first
    /// `beginTooltip()` call, reused across frames. When non-null
    /// AND `tooltip_active_this_frame` is true at endFrame, its
    /// draw list is rendered on top of everything else (after
    /// foreground_dl) so the tooltip overlays popups too.
    tooltip_window: ?*Window = null,
    /// Set by `beginTooltip` when the block opens this frame;
    /// consumed at endFrame to decide whether to render the
    /// tooltip window. Reset to false in beginFrame.
    tooltip_active_this_frame: bool = false,

    /// Drag-drop state. Single-slot - only one drag in flight at
    /// any moment, matching imgui's model. Persists across frames
    /// while a drag is in progress; resets to idle on mouse release.
    drag_drop: DragDropState = .{},
    /// Drag preview window - lazily allocated on first drag.
    /// Reused across frames. Hosts the draw list submitted inside
    /// a `beginDragDropSource` block. Rendered on top of
    /// everything (alongside the tooltip) at endFrame.
    drag_preview_window: ?*Window = null,
    /// Set by `beginDragDropSource` when the block opens this
    /// frame; consumed at endFrame to decide whether to render the
    /// drag preview window. Reset to false in beginFrame.
    drag_preview_active_this_frame: bool = false,

    /// stack of `Style` field snapshots so `pushStyle`
    /// / `popStyle` can temporarily override style values
    /// (e.g. for a custom-padded button group, an accent-colored
    /// section). Bounded - 32 nested pushes is more than any
    /// real app needs. Field name + bytes copy approach handles
    /// every current Style field (all POD: Color, Vec2, f32,
    /// i32, optional pointer).
    style_stack: BoundedStack(StyleSnapshot, 32) = .{},

    /// Windows submitted this frame, in submission order. Replayed
    /// at endFrame. Cleared in beginFrame; appended in openWindow.
    /// Cap of 32 is plenty - real apps rarely exceed 8 simultaneous
    /// windows.
    frame_windows: BoundedStack(*Window, 32) = .{},
    /// Rects of the windows submitted LAST frame. Used only by
    /// `wantCaptureMouse` as an order-independent fallback — see the comment at
    /// the snapshot site in `beginFrameRaw`. Stored as plain rects (not `*Window`)
    /// so it stays valid even if a window is destroyed between frames.
    prev_window_rects: BoundedStack(Rectangle, 32) = .{},

    /// When true (inside a `pushRenderTexture` scope), draw helpers
    /// emit straight to rlgl instead of recording commands. Preserves
    /// the recursive_hud demo's "push RT, draw stuff, pop RT" eager
    /// semantics - UI submitted in an RT scope renders synchronously
    /// to the RT, not deferred to endFrame.
    eager_mode: bool = false,

    /// Active table state when inside a `beginTable` scope.
    /// Single-slot: nested tables are not supported in.
    /// Null outside any table scope.
    active_table: ?TableState = null,

    /// Persistent sort state keyed by table id - survives across
    /// frames so click-to-sort changes "stick" until the user
    /// clicks something else. Lazily populated at
    /// `tableHeadersRow` time; entries never auto-evicted (sort
    /// specs are tiny).
    table_sort_state: std.AutoHashMapUnmanaged(Id, TableSortSpecs) = .empty,

    /// Persistent vertical-scroll offset per table id. Lazily
    /// populated at `beginTable` time when `outer_height > 0`.
    /// Mutated by mouse-wheel handling at row submission.
    /// Clamped + written back at `endTable`.
    table_scroll_state: std.AutoHashMapUnmanaged(Id, f32) = .empty,

    /// P8.4: per-table cache of measured column
    /// content widths, keyed by table id. Populated at endTable
    /// from each `TableColumnState.width_auto_seen`; read back at
    /// beginTable into the new frame's column state. Used by the
    /// content-fit sizing modes (`fixed_fit`, `fixed_same`,
    /// `stretch_prop`) which need the previous frame's measured
    /// widths to size the current frame. Entries never
    /// auto-evicted; the per-id cost is one `[max_table_columns]f32`
    /// (~128 B at max_table_columns=32), small enough to leave
    /// resident.
    table_width_auto_cache: std.AutoHashMapUnmanaged(Id, [max_table_columns]f32) = .empty,

    // ---- Live-frame substate refs
    // These three pointers are the per-frame live substates the UI
    // needs for eager-mode draw paths and for any path that bypasses
    // the deferred draw list (typically the small handful of
    // immediate-mode helpers that emit to GL directly when no
    // current_draw_list is set).
    // Set in `beginFrame` from the caller's Frame. Stale outside
    // of begin/endFrame - never dereference them between frames.
    // Long-lived UiContext fields they're NOT - they're purely a
    // per-frame stash so widgets called inside `update` can find
    // the active gl/shapes/font without threading them through
    // every widget signature.
    // The deferred path (DrawList replay in `endFrame`) takes its
    // substates as explicit parameters and does NOT use these.

    /// Live `*GlState` for the current frame. Used by eager-mode
    /// draw paths (debug/RT scope) which bypass the draw list and
    /// emit directly to GL. Set in `beginFrame`, cleared logically
    /// after `endFrame` (we leave the pointer alive but don't
    /// dereference between frames).
    frame_gl: ?*Gl = null,
    /// Live `*const WindowState` for the current frame, captured at
    /// `beginFrame`.  Read by the deferred-render path so DrawList
    /// replays know the canvas dims and coord conventions.  We hold
    /// only `WindowState`, not the whole `*Frame` — UI has no business
    /// looking at the loader / clock / rng / log / random bits of
    /// `Frame`.  Stays null on the test path (`beginFrameRaw`).
    frame_window: ?*const runtime.core.WindowState = null,
    /// Live shapes-texture cache for the current frame. Used by
    /// eager-mode shape draws.
    frame_shapes_state: ?*const shapes2d.ShapesTextureState = null,
    /// Live font cache for the current frame. Used by eager-mode
    /// text draws + measureTextS.
    frame_font_cache: ?*const text2d.FontCache = null,

    // ---- Surface / render-target composition (recursive UI)
    // The UI tracks a stack of surfaces. Empty stack ⇒ rendering to
    // the main canvas. `pushRenderTexture(rt)` enters an RT scope:
    // FBO bound, viewport + ortho matrices set for RT size.
    // `popRenderTexture()` undoes. Composition is recursive - render
    // a HUD to one RT while another RT is bound, then `image(rt)`
    // both anywhere downstream.

    /// Width of the main canvas in pixels. Populated by `beginFrame`
    /// from the host. Used as the implicit bottom-of-stack surface.
    canvas_w: u32 = 0,
    /// Height of the main canvas in pixels. See `canvas_w`.
    canvas_h: u32 = 0,
    /// Active surface stack. Top = current render target. Bounded
    /// at 8 levels - recursive UIs more than 8 deep are unusual and
    /// likely a bug.
    surface_stack: BoundedStack(Surface, 8) = .{},

    // ---- layout + disabling state
    // pushItemWidth / popItemWidth - push/pop a "preferred width"
    // value that widgets read for their primary input element.
    // setNextItemWidth fires once for the next widget; setting the
    // stack value gives a longer-lived override.
    // beginDisabled / endDisabled - push/pop a counter; while > 0,
    // input is gated and rendering uses an alpha multiplier so
    // widgets read as "greyed out". ImGui semantics exactly.
    // beginGroup / endGroup - wrap a sequence of widgets so the
    // group reads as a single item to `isItemHovered`/`isItemActive`
    // queries that follow `endGroup`. Useful for clusters like
    // "label + reset button" that want to act as one row.

    /// Per-widget width override consumed by the next widget call,
    /// then cleared. ImGui: `SetNextItemWidth`.
    next_item_width: ?f32 = null,
    /// Stacked width override. Top-of-stack value applies to
    /// every subsequent widget until popped. Cap of 8 - deeper
    /// nesting than that is a likely caller bug.
    item_width_stack: BoundedStack(f32, 8) = .{},
    /// Push-count for `beginDisabled`. When non-zero, widgets
    /// silently ignore input AND render with `style.alpha *
    /// disabled_alpha`. Counter form (not bool) so nested
    /// `beginDisabled(false)` blocks compose: an inner
    /// `beginDisabled(false)` doesn't re-enable a parent that
    /// already disabled itself.
    disabled_count: u32 = 0,
    /// Per-scope marker - was THIS `beginDisabled` call's argument
    /// `true`? `endDisabled` consults the top of stack to know
    /// whether to decrement `disabled_count`. `false`-passes are
    /// recorded so the push/pop pairing stays balanced.
    disabled_marker_stack: BoundedStack(bool, 16) = .{},
    /// Saved input snapshot at the moment `disabled_count`
    /// transitioned 0 → 1. Restored when it transitions back to
    /// zero. Only the OUTERMOST disabled scope saves; inner
    /// `beginDisabled(true)` calls just bump the count without
    /// changing the already-suppressed input.
    saved_input_when_disabled: ?InputSnapshot = null,
    /// Alpha multiplier applied to every draw command. 1.0 by
    /// default; reduced inside a `beginDisabled` scope to
    /// `style.disabled_alpha` so widgets render greyed-out.
    alpha_mul: f32 = 1.0,
    /// cross-widget behavior flags stack.
    /// `pushItemFlag(opts)` merges with the existing top via
    /// `mergeItemFlags`; `popItemFlag` pops one entry. Widgets
    /// read `currentItemFlags()` for the merged effective view.
    /// `beginDisabled` continues to maintain its own
    /// `disabled_count` for back-compat; the two systems agree on
    /// "is disabled" via `currentItemFlags().disabled`.
    item_flags_stack: BoundedStack(ItemFlags, 16) = .{},
    /// Open `beginGroup` scopes. Each entry remembers the cursor
    /// position + cursor_max at the moment the group opened, so
    /// `endGroup` can compute the group's bounding rect.
    group_stack: BoundedStack(GroupState, 8) = .{},

    // ---- popup state
    // Popups are mini-windows: they reuse the existing `Window`
    // struct (persistent state in `ctx.windows`, same draw list +
    // cursor pipeline) but draw chrome differently (no title bar,
    // border + bg only) and replay AFTER regular windows so they
    // overlay parent content.
    // Open/closed state survives across frames in `popup_open`,
    // keyed by hashed popup id (parent_window_id ++ str_id).
    // `frame_popups` is the per-frame replay list; `popup_stack`
    // tracks currently-active popups during submission so
    // `closeCurrentPopup` can target the top.

    /// Open popups, keyed by their hashed id. Entries live across
    /// frames; deleted on user dismiss (click outside, click on a
    /// menu item, `closeCurrentPopup`).
    popup_open: std.AutoHashMapUnmanaged(Id, PopupState) = .{},
    /// Popups submitted this frame, in submission order. Replayed
    /// in `endFrame` AFTER `frame_windows` so popups overlay regular
    /// windows. Cleared each `beginFrame`.
    frame_popups: BoundedStack(*Window, 8) = .{},
    /// IDs of currently-active popups during submission - the top is
    /// the innermost popup that `closeCurrentPopup` would close.
    popup_stack: BoundedStack(Id, 8) = .{},

    // ---- main menu bar state
    // The main menu bar is a synthetic always-on "window" rendered
    // on the foreground draw list at the top of the canvas. Its
    // layout cursor advances horizontally as the user submits
    // `beginMenu("File")` etc. Menus inside are popups anchored
    // immediately below their button.

    /// True between `beginMainMenuBar` and `endMainMenuBar`. Widgets
    /// like `beginMenu` check this to know whether to lay out
    /// horizontally (in-bar) vs vertically (in-popup).
    /// True while inside `beginMainMenuBar`/`endMainMenuBar` OR
    /// `beginMenuBar`/`endMenuBar` (per-window). Read by `openMenu`
    /// so menu items can be submitted only inside a bar. The
    /// concrete coordinates (cursor x, origin y, bottom y) live on
    /// ctx and get re-anchored on each bar's open.
    in_menu_bar: bool = false,
    /// Synthetic Window* used by the main menu bar. Allocated lazily
    /// the first time `beginMainMenuBar` runs; reused thereafter.
    main_menu_bar_win: ?*Window = null,
    /// The window whose menu bar is currently active. For
    /// `beginMainMenuBar` this is `main_menu_bar_win` (the synthetic
    /// bar window). For `beginMenuBar` this is the parent window
    /// (the bar is drawn into the parent's pixels). Read by
    /// `openMenu` so it knows where to draw bar buttons / anchor
    /// popups regardless of which path opened the bar.
    active_menu_bar_win: ?*Window = null,
    /// X cursor inside the menu bar - incremented by each
    /// `beginMenu` button. Reset at every `beginMainMenuBar`.
    menu_bar_cursor_x: f32 = 0,
    /// Bottom-y of the menu bar (used by `beginMenu` to anchor its
    /// popup just below the button).
    menu_bar_bottom_y: f32 = 0,
    /// The ONE menu currently open across the menu bars (single-open). The bar's
    /// open INTENT; `popup_open` holds the live popup. null = no menu open.
    bar_open_menu: ?Id = null,
    /// Button rect of the open menu (last frame), so click-outside dismissal can
    /// tell a toggle-click on the button from a genuine outside click.
    bar_open_menu_btn: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 },

    // ---- tab bar state
    // Persistent state keyed by the bar's hashed `str_id` (so two
    // bars with different ids in the same window are independent).
    // Per-frame transient state lives on `tab_bar_stack`, pushed
    // by `beginTabBar` and popped by `endTabBar`.

    /// Persistent per-tab-bar state - selected tab survives frames.
    tab_bar_state: std.AutoHashMapUnmanaged(Id, TabBarState) = .{},
    /// Active tab bars during submission. Cap 4 - nesting tab
    /// bars more than 4 deep is a likely caller bug.
    tab_bar_stack: BoundedStack(TabBarFrame, 4) = .{},

    // Typed-generic per-extension state. Each call to
    // `getOrPutState(T, id, opts)` resolves through here: the outer
    // map is keyed by `@typeName(T)`'s hash and points at a
    // `*AutoHashMapUnmanaged(Id, T)` allocated lazily on the first
    // access for that type. Types never accessed cost nothing.
    //

    // Why type-erased outer pointer (`*anyopaque`) instead of a
    // tagged union? Comptime-known type set isn't viable —
    // extensions are user code that imports `zimr`, so the type
    // set is unknown to zimr at its own compile time. Type
    // erasure + comptime cast on every access gives us a closed
    // loop where the user-facing API is fully type-safe at each
    // call site even though the storage itself is `*anyopaque`.
    //

    // Cleanup: each typed inner map's deinit is registered when
    // the map is created (lazily), keyed by the type-hash.
    // UiContext teardown walks the outer map, calls each
    // registered deinit fn through a function-pointer, then frees
    // the outer map. No leaks even if the user never explicitly
    // clears state.
    ext_storage: std.AutoHashMapUnmanaged(u64, ExtStorageSlot) = .empty,

    // Bounded stack of translate+scale 2D affine transforms.
    // Lives on the context (not on CanvasCtx) because CanvasCtx
    // is returned by-value from beginCanvas; mutating state has
    // to live somewhere stable. One stack shared across canvases
    // is fine for now — nested canvases are an extension worth
    // explicitly designing if/when a real use case shows up.
    //

    // Each entry encodes a 2D affine: a point P in child space
    // becomes P * scale + translate in parent space. Compose:
    // when push happens, the new top is computed by composing
    // the incoming (t, s) with the previous top. Pop just shrinks.
    canvas_transforms: BoundedStack(CanvasTransform, 16) = .{},

    // ---- MultiSelect scope plumbing
    // Three fields wire multi-select into the context:
    //   1. `ms_storage` - persistent per-scope state, keyed by the
    //      scope's id (window-id-stack-top at Begin time). Survives
    //      frames; GC'd via `last_frame_active`. Matches imgui's
    //      `g.MultiSelectStorage`.
    //   2. `current_multi_select` - transient single-slot for the
    //      in-progress Begin/End pair. `null` outside a scope.
    //      Single-slot is the MVP decision;
    //      nested scopes are deferred (see imgui-plan.md deferred
    //      section).
    //   3. `next_item_selection_user_data` - the value most recently
    //      passed to `setNextItemSelectionUserData`; consumed (and
    //      cleared) by the next widget that calls into the
    //      item-side multi-select hooks. Imgui:
    //      `g.NextItemData.SelectionUserData` - note: imgui stores
    //      this regardless of whether a scope is active (so widgets
    //      can pre-tag for future scopes); we follow the same
    //      pattern (`imgui_widgets.cpp:8208-8226`).

    /// Persistent per-scope state, keyed by scope id. Imgui:
    /// `g.MultiSelectStorage`.
    ms_storage: std.AutoHashMapUnmanaged(Id, MultiSelectState) = .{},
    /// In-progress scope, or null. Set by `beginMultiSelect`,
    /// cleared by `endMultiSelect`.
    current_multi_select: ?MultiSelectScope = null,
    /// Pending user-data tag for the next widget. Set by
    /// `setNextItemSelectionUserData`; consumed by the next
    /// widget's item-side hooks. Stored regardless of whether
    /// a scope is active (matches imgui).
    next_item_selection_user_data: ?u64 = null,

    // ---- scalar-input scratch buffer
    // `inputFloat`/`inputInt` operate on caller-owned numeric
    // pointers (`*f32`, `*i32`). While the user is typing, we need
    // a string buffer to hold the edit; on commit we parse it back
    // to T. This is the buffer. Only the currently-focused
    // scalar-input (matched by `active_id`) reads/writes it.

    /// Scratch text buffer for the active scalar input. Seeded with
    /// the current value formatted via `opts.fmt` on focus-gain.
    scalar_edit_buf: [64]u8 = @splat(0),
    /// Used length of `scalar_edit_buf`.
    scalar_edit_len: usize = 0,

    pub fn init(gpa: Allocator) UiContext {
        return .{
            .gpa = gpa,
            .frame_arena = FrameArena.init(gpa, ui_frame_arena_ceiling, "ui"),
        };
    }

    pub fn deinit(self: *UiContext) void {
        // Tear down the persistent maps first. DrawList memory
        // (cmds + payload slices) is all owned by `frame_arena`,
        // so DrawList.deinit is a no-op - `frame_arena.deinit()`
        // below releases it all at once.
        var it: @TypeOf(self.windows.iterator()) = self.windows.iterator();
        while (it.next()) |entry| {
            entry.value_ptr.*.draw_list.deinit(self.frame_arena.allocator());
            self.gpa.destroy(entry.value_ptr.*);
        }
        self.windows.deinit(self.gpa);
        self.tree_open_state.deinit(self.gpa);
        self.popup_open.deinit(self.gpa);
        // TabBarState owns an ArrayList of TabState; deinit
        // each before freeing the bar HashMap itself.
        var tb_it: @TypeOf(self.tab_bar_state.iterator()) = self.tab_bar_state.iterator();
        while (tb_it.next()) |entry| {
            entry.value_ptr.deinit(self.gpa);
        }
        self.tab_bar_state.deinit(self.gpa);
        self.table_sort_state.deinit(self.gpa);
        self.table_scroll_state.deinit(self.gpa);
        self.table_width_auto_cache.deinit(self.gpa);

        // Tear down the typed-generic ext storage. Each entry's
        // `deinit_fn` was captured at first-put time when `T` was
        // comptime-known; we call it through the function pointer
        // to deinit + free the typed inner map. The outer map
        // then releases its own backing.
        var ext_it: @TypeOf(self.ext_storage.iterator()) = self.ext_storage.iterator();
        while (ext_it.next()) |entry| {
            entry.value_ptr.deinit_fn(entry.value_ptr.map, self.gpa);
        }
        self.ext_storage.deinit(self.gpa);
        // release pending persistence entries.
        // Their owned `name` allocations are freed via `clearPending`;
        // the map's own backing capacity then goes away here. Same
        // pattern for the tab-bar staging map.
        clearPending(self);
        self.pending_persistence.deinit(self.gpa);
        self.pending_tab_bar_selections.deinit(self.gpa);
        // release the staged dock tree
        // if persistence loaded one but no dockSpace() was
        // ever submitted to drain it.
        freePendingDockTree(self.gpa, self);
        // docking pool tear-down. Frees every
        // allocated DockNode plus the per-frame bookkeeping lists.
        self.dock.deinit(self.gpa);
        // multi-select scope plumbing.
        self.ms_storage.deinit(self.gpa);
        if (self.current_multi_select) |*scope| {
            scope.requests_buf.deinit(self.gpa);
        }
        // foreground_dl's cmds live on the frame arena - its deinit
        // is effectively a no-op because the arena will free all
        // its pages on the next line. Pass the frame arena's
        // allocator for API symmetry.
        self.foreground_dl.deinit(self.frame_arena.allocator());
        self.background_dl.deinit(self.frame_arena.allocator());
        // Tooltip window (lazily allocated) - free if it was ever
        // created. Its draw list lives on the arena so deinit is
        // a no-op for the cmds list itself.
        if (self.tooltip_window) |tw| {
            tw.draw_list.deinit(self.frame_arena.allocator());
            self.gpa.destroy(tw);
            self.tooltip_window = null;
        }
        if (self.drag_preview_window) |dw| {
            dw.draw_list.deinit(self.frame_arena.allocator());
            self.gpa.destroy(dw);
            self.drag_preview_window = null;
        }
        self.frame_arena.deinit();
    }

    /// Test-friendly form of `beginFrame` that takes pre-built
    /// snapshot + dims + GL primitives directly, skipping the
    /// `*Frame` plumbing. Inline tests use this so they don't
    /// have to construct a fake `Frame` (with all its loader /
    /// clock / rng / log vtables). Production code uses
    /// `beginFrame`; this is the underlying body.
    pub fn beginFrameRaw(
        self: *UiContext,
        snapshot: InputSnapshot,
        input_state: ?*runtime.input.InputState,
        canvas_w: u32,
        canvas_h: u32,
        gl: *Gl,
        shapes_state: *const shapes2d.ShapesTextureState,
        font_cache: *const text2d.FontCache,
    ) Ui {
        self.input = snapshot;
        self.input_state = input_state;
        self.canvas_w = canvas_w;
        self.canvas_h = canvas_h;
        self.frame_gl = gl;
        self.frame_shapes_state = shapes_state;
        self.frame_font_cache = font_cache;
        // on the very first beginFrame after
        // a fresh UiContext, attempt to load persisted window state
        // from localStorage. No-op on host builds and when no
        // `persistence_key` is set. After this call the pending
        // map is populated; the first `findOrCreateWindow` for each
        // persisted name will graft pos/size before opts.initial_*
        // takes effect.
        if (self.frame_count == 0) {
            tryAutoLoad(self);
        }
        self.frame_count += 1;
        // clear per-frame dockspace tracking.
        // Apps using docking re-submit their `dockSpace(...)` widgets
        // each frame; the list captures submissions for drag-drop
        // hit-testing (5.5e). Reset before the user's submissions
        // begin. No-op for apps that don't use docking.
        self.dock.dockspaces_this_frame.clearRetainingCapacity();

        // monotonic UI clock for repeat /
        // hover-delay timing. Reads from the snapshot's delta_time
        // (which beginFrame sets from `f.time.delta_time`). Independent
        // of wall clock - only advances when the ui ticks.
        self.frame_time_seconds += snapshot.delta_time;
        // Tick the shared gesture detector once per frame so widgets can
        // read pinch/drag/swipe off the UI handle. No-op without input.
        if (self.input_state) |istate| {
            var gt: runtime.core.TimeState = .{ .current = self.frame_time_seconds };
            runtime.gestures.update(&self.gestures, istate, &gt);
        }

        // First-frame misconfig diagnostic. The single most-common
        // way to ship a demo that renders BLANK widgets is to bump
        // `style.font_size` (e.g. for phone readability) and forget
        // to also point `style.font` at a loaded TTF. Without a TTF
        // bound, text routes through the bitmap-glyph fallback path,
        // which assumes its built-in 10-px size - bumping font_size
        // tells the layout code to reserve more space, but the actual
        // glyph quads come out at the wrong scale (or simply invisible
        // at common sizes like 16/18/20).
        // We can't physically prevent this - `Style` is a plain struct
        // and Zig has no setters. Instead, on the first frame, we
        // check the misconfig pattern and emit a single diagnostic to
        // stderr (which surfaces as `console.error` in the browser)
        // explaining the exact fix. Fires once per UiContext lifetime
        // so a misconfigured demo doesn't spam the console.
        // Tripped of the imgui-port arc, hence the
        // very specific message.
        // Stay silent under `zig build test`.  Most UI tests use
        // `beginFrameRaw` with no font loaded — and now that the
        // desktop-default `font_size` is 16 (not 10), every such
        // test trips this warning, drowning the test output in
        // hundreds of identical messages.  The warning is for
        // app authors who hit the misconfig live, not for the
        // test harness.  `builtin.is_test` is comptime so the
        // check vanishes from non-test builds.
        if (comptime !builtin.is_test) {
            // Only a genuine misconfig: font_size was bumped off the bitmap-
            // fallback size (10) AND there is no usable font for this frame —
            // neither a `style.font` (the GL mechanism) NOR a loaded
            // `font_cache` (the wgpu `UiHost` mechanism, which supplies the
            // TTF via this very call). Without the font_cache check this fired
            // on EVERY correctly-configured UiHost app, whose `style.font` is
            // legitimately null because the font rides in `font_cache`.
            const has_font: bool = self.style.font != null or font_cache.loaded;
            if (self.frame_count == 1 and !has_font and self.style.font_size != 10) {
                // Route through std.log (-> std_options.logFn -> js_log on wasm),
                // NOT std.debug.print: the latter writes to the raw stderr writer,
                // whose wasm path traps under ReleaseSmall (silent freeze in a
                // release standalone). std.log is the safe, browser-visible path.
                std.log.warn(
                    "zimr: style.font_size is {d:.1} but no font is loaded " ++
                        "(style.font is null and no TTF FontCache was supplied). " ++
                        "Text will render BLANK or at the wrong scale (bitmap " ++
                        "fallback only handles size 10). Fix: load a TTF — wgpu " ++
                        "UiHost: pass it to `z.UiHost.init(gpa, try z.loadFont(f, " ++
                        "gpa, ttf_bytes, size))`; GL: `try z.loadFontDefault(gpa, " ++
                        "&s.font_cache)` AND set `s.ui_ctx.style.font = &s.font_cache.font`.",
                    .{self.style.font_size},
                );
            }
        }
        self.hovered_id_prev_frame = self.hovered_id;
        self.hovered_id = 0;
        self.mouse_wheel_consumed = false;
        self.mouse_wheel_x_consumed = false;
        self.current_window = null;
        self.window_stack = .{};
        self.id_stack = .{};
        self.active_id_just_activated = false;
        self.pending_tooltip = null;
        self.tooltip_active_this_frame = false;
        self.drag_preview_active_this_frame = false;

        // ---- /b: drag-drop state machine
        // opens drags via.idle→.pending→.active.
        // needs the payload to remain readable on the frame the user
        // releases the mouse so targets can accept it during update().
        // That means.active→.idle cleanup runs at endFrame, NOT here.
        // Here we only handle.pending: if the user releases before
        // crossing the drag threshold, cancel; if movement crosses it,
        // promote to.active.
        switch (self.drag_drop.phase) {
            .idle => {},
            .pending => {
                if (!self.input.mouse_left_down) {
                    // Click released before threshold crossed → cancel.
                    self.drag_drop.phase = .idle;
                } else {
                    const dx: f32 = self.input.mouse_pos[0] - self.drag_drop.drag_start_pos[0];
                    const dy: f32 = self.input.mouse_pos[1] - self.drag_drop.drag_start_pos[1];
                    if (dx * dx + dy * dy >= DragDropState.DRAG_THRESHOLD * DragDropState.DRAG_THRESHOLD) {
                        self.drag_drop.phase = .active;
                        debugLogPush(
                            self,
                            .drag_started,
                            "source_id={x}",
                            .{self.drag_drop.source_widget_id},
                        );
                    }
                }
            },
            .active => {
                // Cleanup deferred to endFrame so this frame's target
                // accepts can read the payload.
            },
        }
        // Reset the per-frame "inside a target scope" pointer; widgets
        // may set it during this frame's submission via
        // beginDragDropTarget.
        self.drag_drop.target_widget_id = 0;

        // ---- keyboard nav advance
        // Process Tab from the PREVIOUS frame's submitted nav items.
        // Forward (just Tab) advances to the next item; backward
        // (Shift+Tab) steps to the previous. Wraps at both ends. If
        // nav_id isn't in the current list (e.g. focused widget went
        // away), focus the first item. Done BEFORE clearing the list,
        // then we clear so this frame's submissions can repopulate.
        if (self.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame) {
            advanceNav(self);
        }
        self.frame_nav_items.clear();

        // Surface stack always starts empty each frame - implicit
        // canvas surface. If user pushes RTs and forgets to pop,
        // they bleed into the next frame's main render; we'd rather
        // detect that than silently mask it.
        self.surface_stack.clear();

        // Reset draw list state. Foreground draw list cleared (fresh
        // tooltips / popups each frame). Per-window draw lists are
        // cleared in openWindow when the user resubmits the window;
        // windows not submitted this frame retain last frame's
        // contents but won't be replayed (they're not in frame_windows).
        self.foreground_dl.clear();
        self.dock_tabs_dl.clear();
        self.background_dl.clear();
        // NOTE: `prev_window_rects` is deliberately NOT cleared here — it holds
        // last frame's snapshot, taken in `endFrame` while those windows were
        // still alive. Rebuilding it here instead would mean dereferencing
        // `frame_windows`' `*Window`s a frame later, which can dangle.
        self.frame_windows.clear();
        self.frame_popups.clear();
        self.popup_stack.clear();
        // Reclaim the previous frame's per-frame allocations. Every `add*` call
        // dupes its payload (polyline points, triangle fans, text glyph slices)
        // into `frame_arena`, and the DrawList `cmds` lists are duped here too;
        // all of it is consumed by `uiRenderNow` during the previous frame's
        // render and is dead by the time we reach the next beginFrame. The
        // DrawList docs and `clear()` (which zeros to `.empty` rather than
        // `clearRetainingCapacity`) are written precisely around this reset —
        // without it the arena grew ~one frame's draw payload every frame
        // (≈2 MB/frame in the heaviest 2D scenes), eventually exhausting wasm
        // linear memory.
        //
        // Before freeing the pages, EVERY persistent draw list must be emptied:
        // a window (or the tooltip / drag-preview window) not resubmitted this
        // frame would otherwise keep a `cmds.items.ptr` into reclaimed pages.
        // foreground/background/dock_tabs were just cleared; do the rest here.
        // `.clear()` abandons the stale slice (→ `.empty`); resubmitted windows
        // refill from the fresh arena via openWindow. `.retain_capacity` keeps
        // the backing pages so we don't re-grow wasm memory each frame.
        var win_it: @TypeOf(self.windows.iterator()) = self.windows.iterator();
        while (win_it.next()) |entry| {
            entry.value_ptr.*.draw_list.clear();
        }
        if (self.tooltip_window) |tw| {
            tw.draw_list.clear();
        }
        if (self.drag_preview_window) |dw| {
            dw.draw_list.clear();
        }
        _ = self.frame_arena.reset(.retain_capacity);
        self.current_draw_list = null;
        self.eager_mode = false;
        // dismiss any open popups whose rects don't contain
        // a click that fired this frame. Done at frame start so the
        // user's update() callback sees the correct popup_open state.
        dismissPopupsOnClickOutside(self);

        return Ui{ .ctx = self };
    }

    /// Finalize the frame WITHOUT rendering (no deferred GL queue). For
    /// single-pass backends (WebGPU): call this, then uiRenderNow(gl, ...)
    /// directly. Does everything endFrame does EXCEPT append to
    /// gl.pending_renders — the tooltip flush, dock-tab emission, and metrics,
    /// so the draw lists are in their final per-frame state for the immediate
    /// replay.
    pub fn endFrameNoRender(self: *UiContext) void {
        self.next_widget_take_focus = false;
        // Snapshot this frame's window RECTS while the `*Window`s are still
        // alive and their layout is final. `wantCaptureMouse` hit-tests this as
        // a fallback next frame, so it answers correctly even when called BEFORE
        // that frame's windows have been submitted — which is the natural way to
        // write an update (gate the camera/scene input, THEN draw the UI).
        // Without it, an early call sees an empty `frame_windows`, returns false,
        // and the pointer "clicks through" the panel: tapping a button also drags
        // the scene underneath it. Dear ImGui has the same semantics
        // (WantCaptureMouse reflects the previous frame's layout). Rects (not
        // pointers) are stored, so a window destroyed before the next frame can't
        // dangle.
        self.prev_window_rects.clear();
        for (self.frame_windows.items[0..self.frame_windows.len]) |w| {
            self.prev_window_rects.append(.{
                .x = w.pos[0],
                .y = w.pos[1],
                .width = w.size[0],
                .height = w.size[1],
            }) catch {};
        }
        // Apply queued dock operations (drag-dock / undock / split) and sync the
        // window-side link. THIS is the only end-of-frame path the wgpu UiHost
        // takes (endFrame() is the GL/host path) — without these two lines,
        // drag-to-dock requests were enqueued by the release handler and never
        // drained, so nothing ever docked at runtime.
        processRequests(&self.dock, self.gpa) catch {};
        syncDockedWindowIds(self);
        bringFocusedFloatingToFront(self);
        if (self.pending_tooltip) |t| {
            const saved: ?*DrawList = self.current_draw_list;
            self.current_draw_list = &self.foreground_dl;
            renderTooltip(self, t);
            self.current_draw_list = saved;
        }
        flushDockTabsToForeground(self);
        metricsCollectAtEndFrame(self);
        metricsResetRenderCounters(self);
        metricsAccumulateDrawList(self, &self.background_dl);
        for (self.frame_windows.items[0..self.frame_windows.len]) |w| {
            metricsAccumulateDrawList(self, &w.draw_list);
        }
        for (self.frame_popups.items[0..self.frame_popups.len]) |p| {
            metricsAccumulateDrawList(self, &p.draw_list);
        }
        metricsAccumulateDrawList(self, &self.foreground_dl);
    }
};

// ============================================================================
// Widget: TextFilter - substring include/exclude filter
// ============================================================================
// Maps `ImGuiTextFilter` 1:1. Holds a small input buffer the user
// types filter terms into (e.g. `"error,-test"`), parses it into
// include/exclude segments, and offers `passFilter(text)` so the
// caller can decide per-line whether to render.
// Syntax (matches imgui exactly, see imgui.cpp `PassFilter`):
//   "foo" → include: pass anything containing "foo"
//   "foo,bar" → include union: pass anything containing "foo" OR "bar"
//   "-foo" → exclude: drop anything containing "foo"
//   "foo,-bar" → first match wins (order matters in imgui too):
//                     "foo bar" passes via the "foo" include before
//                     "-bar" is ever consulted. This is imgui's
//                     intentional behaviour; we match it for parity.
// Empty filter passes everything. All-exclude filter passes anything
// not excluded. Case-insensitive matching (ASCII only - matches
// imgui's `ImStristr`).
// Storage caps (256 buffer / 16 segments) match imgui's
// `InputBuf[256]` and the practical max grep count. Both are tunable
// at compile time via the pub constants.

inline fn isAsciiBlank(c: u8) bool {
    return c == ' ' or c == '\t';
}

inline fn asciiLower(c: u8) u8 {
    return if (c >= 'A' and c <= 'Z') c + ('a' - 'A') else c;
}

/// Case-insensitive ASCII substring search. Returns true if
/// `haystack` contains `needle` ignoring case on `[A-Za-z]`. Empty
/// needle matches everything (imgui-parity edge: callers in
/// `passFilter` never pass empty needles because `build` skipped
/// empty segments).
fn containsIgnoreCaseAscii(haystack: []const u8, needle: []const u8) bool {
    if (needle.len == 0) {
        return true;
    }
    if (needle.len > haystack.len) {
        return false;
    }
    // Slide along haystack looking for the start byte (case-insensitive).
    // Naive O(n*m); good enough for filter lengths (<<256).
    var i: usize = 0;
    while (i + needle.len <= haystack.len) : (i += 1) {
        var match: bool = true;
        var j: usize = 0;
        while (j < needle.len) : (j += 1) {
            if (asciiLower(haystack[i + j]) != asciiLower(needle[j])) {
                match = false;
                break;
            }
        }
        if (match) return true;
    }
    return false;
}

pub const TextFilter = struct {
    pub const buf_capacity: usize = 256;
    pub const max_segments: usize = 16;

    /// Input buffer. Mutated directly by `inputText` inside `draw`.
    /// Always NUL-terminator-friendly (last byte reserved as 0).
    buf: [buf_capacity]u8 = @splat(0),
    /// Number of bytes used in `buf` (exclusive of any trailing 0).
    buf_len: usize = 0,

    /// Parsed segments. Each (start, len) is a slice into `buf`.
    /// `negate=true` marks an exclude (prefix `-` was present).
    segments: [max_segments]Segment = @splat(.{ .start = 0, .len = 0, .negate = false }),
    segment_count: usize = 0,
    /// Count of non-negated segments. When 0 (filter is empty or
    /// all-exclude), `passFilter` defaults to "pass" unless a
    /// negate hits.
    include_count: usize = 0,

    pub const Segment = struct {
        start: usize,
        len: usize,
        negate: bool,
    };

    /// Parse `buf[0..buf_len]` into `segments`. Idempotent; safe to
    /// call every frame. `draw` does this automatically when
    /// inputText reports a change.
    /// Algorithm matches imgui's `Build`: split on `,`, trim each
    /// piece's whitespace, skip empty pieces, mark `-`-prefixed pieces
    /// as negated (and trim the `-` off the segment slice itself).
    pub fn build(self: *TextFilter) void {
        self.segment_count = 0;
        self.include_count = 0;
        assertf(
            self.buf_len <= buf_capacity,
            @src(),
            "TextFilter.build: buf_len={d} exceeds capacity={d}",
            .{ self.buf_len, buf_capacity },
        );

        var i: usize = 0;
        while (i < self.buf_len) {
            // Find the next comma (or end).
            var end: usize = i;
            while (end < self.buf_len and self.buf[end] != ',') {
                end += 1;
            }

            // Trim leading whitespace.
            var s: usize = i;
            while (s < end and isAsciiBlank(self.buf[s])) {
                s += 1;
            }
            // Trim trailing whitespace.
            var e: usize = end;
            while (e > s and isAsciiBlank(self.buf[e - 1])) {
                e -= 1;
            }

            if (e > s) {
                var neg: bool = false;
                if (self.buf[s] == '-') {
                    neg = true;
                    s += 1;
                    // Re-trim leading whitespace after the '-' - imgui's
                    // implementation doesn't, but accepting "- foo" feels
                    // more forgiving and matches no test we'd otherwise
                    // be reproducing. Cheap +5 LOC, more humane.
                    while (s < e and isAsciiBlank(self.buf[s])) {
                        s += 1;
                    }
                }
                if (e > s and self.segment_count < max_segments) {
                    self.segments[self.segment_count] = .{
                        .start = s,
                        .len = e - s,
                        .negate = neg,
                    };
                    self.segment_count += 1;
                    if (!neg) {
                        self.include_count += 1;
                    }
                }
            }

            // Advance past the comma.
            i = end + 1;
        }
    }

    /// True if `text` should be displayed under the current filter.
    /// Empty filter → always true. Otherwise iterates segments in
    /// input order; first matching include returns true, first
    /// matching exclude returns false. If no include ever matches
    /// AND at least one include is defined, returns false (default-
    /// deny once includes are present). Matches imgui exactly.
    pub fn passFilter(self: *const TextFilter, text: []const u8) bool {
        if (self.segment_count == 0) {
            return true;
        }

        var k: usize = 0;
        while (k < self.segment_count) : (k += 1) {
            const seg = self.segments[k];
            assertf(
                seg.start + seg.len <= self.buf_len,
                @src(),
                "TextFilter.passFilter: segment {d} range {d}+{d} exceeds buf_len={d}",
                .{ k, seg.start, seg.len, self.buf_len },
            );
            const needle = self.buf[seg.start .. seg.start + seg.len];
            const hit = containsIgnoreCaseAscii(text, needle);
            if (seg.negate) {
                if (hit) return false;
            } else {
                if (hit) return true;
            }
        }
        // No include matched. If includes existed at all, default-deny.
        // If only excludes existed (or nothing), default-allow.
        return self.include_count == 0;
    }

    /// True if any segment is parsed (filter does something).
    pub fn isActive(self: *const TextFilter) bool {
        return self.segment_count > 0;
    }

    /// Reset to empty buffer + zero segments.
    pub fn clear(self: *TextFilter) void {
        self.buf = @splat(0);
        self.buf_len = 0;
        self.segment_count = 0;
        self.include_count = 0;
    }

    /// Render the filter's input box and re-parse on any change.
    /// Returns true if the filter (and therefore which lines pass)
    /// changed this frame - callers may use that to invalidate
    /// caches. `label` and `hint` follow imgui's
    /// `Draw("Filter (inc,-exc)")` convention; `hint` is shown
    /// inside the empty input so users discover the syntax.
    pub fn draw(
        self: *TextFilter,
        ui_handle: Ui,
        label: []const u8,
        hint: []const u8,
    ) bool {
        const changed: bool = ui_handle.inputTextWithHint(
            label,
            hint,
            &self.buf,
            &self.buf_len,
            .{},
        );
        if (changed) {
            self.build();
        }
        return changed;
    }
};

test "TextFilter - empty buffer passes everything" {
    var f: TextFilter = .{};
    f.build();
    try expect(f.passFilter("anything"));
    try expect(f.passFilter(""));
    try expect(!f.isActive());
}

test "TextFilter - single include" {
    var f: TextFilter = .{};
    const term: []const u8 = "error";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expectEqual(@as(usize, 1), f.segment_count);
    try expectEqual(@as(usize, 1), f.include_count);
    try expect(f.passFilter("ERROR: boom")); // case-insensitive
    try expect(f.passFilter("an error here"));
    try expect(!f.passFilter("warning"));
}

test "TextFilter - single exclude" {
    var f: TextFilter = .{};
    const term: []const u8 = "-test";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expectEqual(@as(usize, 1), f.segment_count);
    try expectEqual(@as(usize, 0), f.include_count);
    try expect(!f.passFilter("running test foo"));
    try expect(f.passFilter("plain log line"));
    // segment stored with negate=true and the '-' trimmed off:
    try expectEqual(true, f.segments[0].negate);
    try expectEqualSlices(
        u8,
        "test",
        f.buf[f.segments[0].start .. f.segments[0].start + f.segments[0].len],
    );
}

test "TextFilter - multi-segment union semantics" {
    var f: TextFilter = .{};
    const term: []const u8 = "error,warn";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expectEqual(@as(usize, 2), f.segment_count);
    try expect(f.passFilter("ERROR: x"));
    try expect(f.passFilter("warning"));
    try expect(!f.passFilter("info"));
}

test "TextFilter - include + exclude, order matters" {
    var f: TextFilter = .{};
    const term: []const u8 = "error,-test";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    // "error in test.zig" - "error" include hits FIRST, returns true.
    // (Imgui's documented behaviour - order matters.)
    try expect(f.passFilter("error in test.zig"));
    // "warning in test.zig" - "error" misses, "-test" hits, false.
    try expect(!f.passFilter("warning in test.zig"));
    // Plain unrelated text: "error" misses, "-test" misses, but
    // include_count > 0 → default-deny.
    try expect(!f.passFilter("info plain"));
}

test "TextFilter - whitespace trim around segments" {
    var f: TextFilter = .{};
    const term: []const u8 = "  foo  , -  bar  ";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expectEqual(@as(usize, 2), f.segment_count);
    // Segment 0: "foo", not negated.
    try expectEqualSlices(u8, "foo", f.buf[f.segments[0].start .. f.segments[0].start + f.segments[0].len]);
    try expectEqual(false, f.segments[0].negate);
    // Segment 1: "bar", negated. Note the "- bar " - the leading
    // dash, then whitespace, then bar. We trim both.
    try expectEqualSlices(u8, "bar", f.buf[f.segments[1].start .. f.segments[1].start + f.segments[1].len]);
    try expectEqual(true, f.segments[1].negate);
}

test "TextFilter - empty segments dropped" {
    var f: TextFilter = .{};
    const term: []const u8 = ",,foo,,";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expectEqual(@as(usize, 1), f.segment_count);
}

test "TextFilter - clear" {
    var f: TextFilter = .{};
    const term: []const u8 = "err";
    @memcpy(f.buf[0..term.len], term);
    f.buf_len = term.len;
    f.build();
    try expect(f.isActive());
    f.clear();
    try expect(!f.isActive());
    try expect(f.passFilter("anything"));
    try expectEqual(@as(usize, 0), f.buf_len);
}

/// The handle the user holds. Cheap value type - 8 bytes (single
/// pointer). All real state lives in the pointed-to UiContext.
/// Sits at `Frame.ui`.
/// A frame's pinch state, returned by `Ui.pinch()`. `scale` is the
/// frame-to-frame two-finger distance ratio (1.0 = no pinch); `mid` is
/// the screen-space focal point to hold fixed while zooming.
pub const Pinch = struct {
    scale: f32 = 1,
    mid: Vec2 = .{ 0, 0 },
};

/// The gesture the shared detector classified this frame (tap, doubletap,
/// hold, drag, swipe_*, pinch_*). Read via `Ui.gesture()`.
pub const Gesture = runtime.gestures.Gesture;

pub const WindowOpts = struct {
    /// Initial position when the window is first created. After
    /// that, the persistent position is used. Pass null to use the
    /// auto-tile default.
    initial_pos: ?Vec2 = null,
    /// Initial size when the window is first created.
    initial_size: Vec2 = .{ 320, 200 },
    /// Flag set controlling chrome suppression + future behaviors.
    /// See `WindowFlags` for the full menu. shipped
    /// the six chrome-suppression flags; P5.2+ extend this.
    flags: WindowFlags = .{},
};

/// Effective title-bar height for `w` — `style.title_bar_height`
/// when the window has a title bar, `0` when `flags.no_title_bar`
/// is set. Use at every layout site that subtracts/adds the title
/// bar's vertical contribution: content cursor origin, viewport
/// height, scroll clip, content-natural-height in auto-expand.
fn effectiveTitleBarHeight(ctx: *const UiContext, w: *const Window) f32 {
    if (w.flags.no_title_bar) {
        return 0;
    }
    return ctx.style.title_bar_height;
}

/// Compute a widget's ID by hashing `label` against the current ID
/// scope. The scope is the top of `ctx.id_stack` if any
/// `pushId`/`pushIdInt` is active, otherwise the current window's
/// ID. ALL widget impls should use this rather than calling
/// `hashStr(w.id, label)` directly - the difference matters when
/// the caller wraps a loop in `pushIdInt(i)` to disambiguate IDs
/// across iterations (e.g. `editArrayList`'s per-element delete
/// buttons).
pub fn widgetId(
    ctx: *UiContext,
    w: *Window,
    label: []const u8,
) Id {
    const seed: Id = ctx.id_stack.top() orelse w.id;
    return hashStr(seed, label);
}

/// Vertical scrollbar - track + draggable thumb on the right edge of
/// the window. minimum: thumb-drag and mouse-wheel both
/// update `w.scroll_y`; track-clicks (jump to position) are deferred.
/// Rendered LAST in `closeWindow` so it overlays content; ID hashed
/// from the window's id so it's stable + unique across windows.
/// Vertical scrollbar - track + draggable thumb on the right edge
/// of the window's content area. Uses `w.layout.origin[1]` /
/// `w.layout.work_rect_max[1]` for the track's vertical bounds so
/// the same function works for both top-level windows (where
/// `origin.y = pos.y + title_bar + padding`) and child windows
/// (where `origin.y = pos.y + opts.padding`) without branching.
/// Caller responsibility:
///   - Must be called AFTER the content clip rect is popped (so
///     the scrollbar isn't clipped out alongside the content).
///   - Must verify `scroll_max_y > 0` before calling - a window
///     with content that fits doesn't need a scrollbar.
///   - The window's `scroll_y` is mutated in-place if the user
///     drags the thumb.
fn renderScrollbar(ctx: *UiContext, w: *Window) void {
    const scrollbar_w: f32 = 10;
    const track_x: f32 = w.pos[0] + w.size[0] - scrollbar_w - 2;
    const track_y: f32 = w.layout.origin[1];
    const track_h: f32 = @max(0, w.layout.work_rect_max[1] - w.layout.origin[1]);
    if (track_h <= 0 or w.scroll_max_y <= 0) {
        return;
    }
    // Visible viewport height == track height (the scrollbar
    // overlays the visible content area exactly). Content height
    // is the scrollable extent: viewport + scroll_max.
    const viewport_h: f32 = track_h;
    const content_h: f32 = viewport_h + w.scroll_max_y;

    // Thumb size proportional to viewport/content ratio; clamped
    // to a minimum of 16 px so a deeply overflowing window still
    // has a grabbable thumb.
    const min_thumb: f32 = 16;
    const ratio: f32 = clamp(viewport_h / content_h, 0, 1);
    const thumb_h: f32 = @max(min_thumb, ratio * track_h);
    // Thumb position interpolates linearly from scroll_y / scroll_max.
    const t: f32 = if (w.scroll_max_y > 0) w.scroll_y / w.scroll_max_y else 0;
    const thumb_y: f32 = track_y + t * (track_h - thumb_h);

    // ---- Hit-test + drag --------------------------------------------
    // Visible thumb is `scrollbar_w` (10 px) wide — fine for mouse,
    // far below the ~44–48 px touch-target minimum.  Hit-test rect
    // inflates leftward (into content area) by `touch_inflate` so a
    // fingertip landing in a ~32-px-wide band to the left of the
    // bar still registers.  Leftward only — rightward would push
    // the hit region past the window's right edge.
    const touch_inflate: f32 = 22;
    const hit_rect: Rectangle = .{
        .x = track_x - touch_inflate,
        .y = thumb_y,
        .width = scrollbar_w + touch_inflate,
        .height = thumb_h,
    };
    const visual_thumb_rect: Rectangle = .{
        .x = track_x,
        .y = thumb_y,
        .width = scrollbar_w,
        .height = thumb_h,
    };
    const scroll_id: Id = widgetId(ctx, w, "##scroll_thumb");
    const mp: Vec2 = ctx.input.mouse_pos;
    const md: bool = ctx.input.mouse_left_down;
    const mp_in_thumb: bool = pointInRect(mp, hit_rect);

    if (md and ctx.active_id == 0 and mp_in_thumb) {
        ctx.active_id = scroll_id;
        ctx.active_id_just_activated = true;
        // Stash the thumb-y vs mouse-y delta so the thumb doesn't
        // jump on first drag frame.
        ctx.active_id_press_value = @floatCast(mp[1] - thumb_y);
    }
    if (ctx.active_id == scroll_id) {
        if (md) {
            const grab_dy: f32 = @floatCast(ctx.active_id_press_value);
            const new_thumb_y: f32 = mp[1] - grab_dy;
            const new_t: f32 = (new_thumb_y - track_y) / @max(1, track_h - thumb_h);
            w.scroll_y = @max(0, @min(w.scroll_max_y, new_t * w.scroll_max_y));
        } else {
            ctx.active_id = 0;
        }
    }

    // ---- Render: track + thumb -------------------------------------
    drawRectFilled(
        ctx,
        .{ .x = track_x, .y = track_y, .width = scrollbar_w, .height = track_h },
        Color.toWire(ctx.style.scrollbar_bg),
    );
    const thumb_col: Color = if (ctx.active_id == scroll_id)
        ctx.style.scrollbar_grab_active
    else if (mp_in_thumb)
        ctx.style.scrollbar_grab_hovered
    else
        ctx.style.scrollbar_grab;
    drawRectFilled(
        ctx,
        visual_thumb_rect,
        Color.toWire(thumb_col),
    );
}

/// P5.5b: horizontal scrollbar — straight mirror of
/// `renderScrollbar`, rotated 90°. Track + draggable thumb on the
/// bottom edge of the window's content area. Same caller contract:
///   - Called AFTER content clip pop (so scrollbar isn't clipped).
///   - Caller must verify `scroll_max_x > 0` AND
///     `flags.horizontal_scrollbar` AND `!flags.no_scrollbar` before
///     calling — `closeWindow` enforces this.
///   - Mutates `w.scroll_x` in place on thumb drag.
fn renderScrollbarX(ctx: *UiContext, w: *Window) void {
    const scrollbar_h: f32 = 10;
    // Track sits on the bottom edge, just inside the border. Leave a
    // 12px gap on the right so the X bar doesn't visually collide
    // with the Y bar's track when both are shown.
    const corner_gap: f32 = if (w.scroll_max_y > 0) 12 else 2;
    const track_x: f32 = w.layout.origin[0];
    const track_y: f32 = w.pos[1] + w.size[1] - scrollbar_h - 2;
    const track_w: f32 = @max(0, (w.pos[0] + w.size[0] - corner_gap) - w.layout.origin[0]);
    if (track_w <= 0 or w.scroll_max_x <= 0) {
        return;
    }
    const viewport_w: f32 = track_w;
    const content_w: f32 = viewport_w + w.scroll_max_x;

    // Thumb size proportional to viewport/content; min 16px so a deep
    // overflow still leaves a grabbable handle.
    const min_thumb: f32 = 16;
    const ratio: f32 = clamp(viewport_w / content_w, 0, 1);
    const thumb_w: f32 = @max(min_thumb, ratio * track_w);
    const t: f32 = if (w.scroll_max_x > 0) w.scroll_x / w.scroll_max_x else 0;
    const thumb_x: f32 = track_x + t * (track_w - thumb_w);

    // ---- Hit-test + drag --------------------------------------------
    // The VISIBLE thumb is `scrollbar_h` (10 px) tall — fine for
    // mouse, far below the ~44–48 px touch-target minimum.  The
    // HIT-TEST rect inflates upward by `touch_inflate` so a
    // fingertip landing anywhere in a ~32-px-tall band above the
    // bar still registers as a thumb press.  Inflation is upward
    // only (into the content area) because downward would push the
    // hit region off the window's bottom edge or past the home
    // indicator on phones.  Desktop mouse hits the visual thumb
    // exactly as before; touch gains a much more forgiving target.
    const touch_inflate: f32 = 22;
    const hit_rect: Rectangle = .{
        .x = thumb_x,
        .y = track_y - touch_inflate,
        .width = thumb_w,
        .height = scrollbar_h + touch_inflate,
    };
    const visual_thumb_rect: Rectangle = .{
        .x = thumb_x,
        .y = track_y,
        .width = thumb_w,
        .height = scrollbar_h,
    };
    const scroll_id: Id = widgetId(ctx, w, "##scroll_thumb_x");
    const mp: Vec2 = ctx.input.mouse_pos;
    const md: bool = ctx.input.mouse_left_down;
    const mp_in_thumb: bool = pointInRect(mp, hit_rect);

    if (md and ctx.active_id == 0 and mp_in_thumb) {
        ctx.active_id = scroll_id;
        ctx.active_id_just_activated = true;
        // Stash thumb-x vs mouse-x delta so the thumb doesn't jump
        // on first drag frame. Reuse `active_id_press_x` for the
        // X stash (Y's drag uses `active_id_press_value`).
        ctx.active_id_press_x = mp[0] - thumb_x;
    }
    if (ctx.active_id == scroll_id) {
        if (md) {
            const grab_dx: f32 = ctx.active_id_press_x;
            const new_thumb_x: f32 = mp[0] - grab_dx;
            const new_t: f32 = (new_thumb_x - track_x) / @max(1, track_w - thumb_w);
            w.scroll_x = @max(0, @min(w.scroll_max_x, new_t * w.scroll_max_x));
        } else {
            ctx.active_id = 0;
        }
    }

    // ---- Render: track + thumb --------------------------------------
    drawRectFilled(
        ctx,
        .{ .x = track_x, .y = track_y, .width = track_w, .height = scrollbar_h },
        Color.toWire(ctx.style.scrollbar_bg),
    );
    const thumb_col: Color = if (ctx.active_id == scroll_id)
        ctx.style.scrollbar_grab_active
    else if (mp_in_thumb)
        ctx.style.scrollbar_grab_hovered
    else
        ctx.style.scrollbar_grab;
    drawRectFilled(
        ctx,
        visual_thumb_rect,
        Color.toWire(thumb_col),
    );
}

fn closeWindow(ctx: *UiContext, w: *Window) void {
    // ---- Auto-fit window size to content (behaviour)
    // Only applies when the user hasn't manually resized AND hasn't
    // passed an explicit `setNextWindowSize`. Once `user_resized` is
    // true, content overflow shows a scrollbar instead of growing
    // the window - that's the entire point of giving it a size.
    // Note: `cursor_max` is in scrolled coordinates (it includes the
    // scroll_y shift applied at openWindow), so we add scroll_y back
    // to recover the un-shifted "natural" content extent.
    const inner_origin_y: f32 = w.pos[1] + effectiveTitleBarHeight(ctx, w) + ctx.style.window_padding[1];
    const content_h_natural: f32 = w.layout.cursor_max[1] + w.scroll_y - inner_origin_y;
    const content_w_natural: f32 = w.layout.cursor_max[0] + w.scroll_x - w.pos[0] + ctx.style.window_padding[0];
    // P5.2: `always_auto_resize` matches imgui's
    // `ImGuiWindowFlags_AlwaysAutoResize` semantics — every frame,
    // window matches its content's natural extent (BOTH grow AND
    // shrink), ignoring `user_resized`. Normal behavior (no flag)
    // only grows, and pins once the user has dragged.
    if (w.flags.always_auto_resize) {
        const total_h: f32 = content_h_natural + effectiveTitleBarHeight(ctx, w) + ctx.style.window_padding[1];
        w.size[0] = content_w_natural;
        w.size[1] = total_h;
    } else if (!w.user_resized) {
        if (content_w_natural > w.size[0]) {
            w.size[0] = content_w_natural;
        }
        const total_h: f32 = content_h_natural + effectiveTitleBarHeight(ctx, w) + ctx.style.window_padding[1];
        if (total_h > w.size[1]) {
            w.size[1] = total_h;
        }
    }

    // lint #5: content overflow. v7 spec phrased this as
    // "cursor_y > inner.y + region.h with no scrollbar", but zimr
    // currently auto-shows the scrollbar whenever scroll_max_y > 0,
    // so the literal condition never holds. The grounded variant:
    // when the auto-expanded window grew taller than the canvas, OR
    // when the user-sized window's content overflows by more than
    // 3× its viewport (scrollbar travel ratio so tight it's nearly
    // unusable). Either case usually means the caller wanted an
    // explicit size, a smaller font, or a scroll region they
    // forgot.
    if (!w.user_resized) {
        const canvas_h_f: f32 = float(ctx.canvas_h);
        if (w.pos[1] + w.size[1] > canvas_h_f) {
            warnOnce(
                @src(),
                "ui.window: auto-expanded '{s}' (h={d:.0}) extends below canvas " ++
                    "(h={d:.0}); consider setNextWindowSize",
                .{ w.name(), w.size[1], canvas_h_f },
            );
        }
    } else {
        // Sized window — scrollbar present whenever there's
        // overflow, so the bug is "viewport tiny relative to
        // content". Warn once at 3× ratio.
        const viewport_h_local: f32 = @max(
            1,
            w.size[1] - effectiveTitleBarHeight(ctx, w) - ctx.style.window_padding[1],
        );
        if (content_h_natural > viewport_h_local * 3) {
            warnOnce(
                @src(),
                "ui.window: '{s}' content ({d:.0}px) is >3× its viewport ({d:.0}px); consider enlarging the window",
                .{ w.name(), content_h_natural, viewport_h_local },
            );
        }
    }

    // ---- Compute scroll bookkeeping ----------------------------------
    const viewport_h: f32 = @max(0, w.size[1] - effectiveTitleBarHeight(ctx, w) - ctx.style.window_padding[1]);
    w.scroll_max_y = @max(0, content_h_natural - viewport_h);
    // Shift-wheel pan: when the user holds Shift, vertical wheel
    // ticks route to horizontal scroll instead of vertical.  Common
    // OS convention (Firefox/Chrome do this); wide content lanes
    // (timelines, code with long lines) get keyboard-mouse-only
    // horizontal navigation without needing a trackpad gesture.
    // Only fires when the window actually has horizontal capacity
    // (`horizontal_scrollbar` flag + `scroll_max_x > 0`); falls
    // through to normal Y handling otherwise so the user doesn't
    // feel a dead key.
    const shift_pan_active: bool = (ctx.input.keys[@intFromEnum(KeyCode.left_shift)].down or
        ctx.input.keys[@intFromEnum(KeyCode.right_shift)].down) and
        w.flags.horizontal_scrollbar and
        ctx.hovered_window_id == w.id and
        ctx.input.mouse_wheel_y != 0 and
        !ctx.mouse_wheel_x_consumed and
        !w.flags.no_scrollbar;
    if (shift_pan_active) {
        const wheel_speed: f32 = 5 * ctx.style.font_size;
        w.scroll_x -= ctx.input.mouse_wheel_y * wheel_speed;
        ctx.mouse_wheel_x_consumed = true;
        ctx.mouse_wheel_consumed = true;
    }
    // Touch-pan scroll: when the user drags across the window body
    // and no widget claimed the input (button/slider/etc), translate
    // the drag delta into scroll_y / scroll_x.  This is the mobile-
    // touch equivalent of mouse-wheel scrolling — desktop sites
    // generate `wheel` events from trackpad/mousewheel, but phones
    // only generate mouse-down/move/up from finger drags.  Without
    // this path, content that overflows is reachable only by
    // grabbing the thin scrollbar thumb — a 10 px wide target.
    //
    // Activation rule: hovered window, left-mouse held, active_id
    // is 0 (no widget grabbed it), scrollbar not suppressed, AND
    // there's actually somewhere to scroll.  Once activated, the
    // window keeps the pan active until release even if the cursor
    // wanders over a widget — avoids the gesture cancelling mid-
    // swipe when the finger crosses a button.
    //
    // Per-axis: Y always active when scroll_max_y > 0.  X only
    // when the horizontal_scrollbar flag is set (matches the wheel
    // gate at line 14046).  Pan is recomputed each frame from the
    // gesture start to avoid integration drift; captured offsets
    // live on the Window.
    const hovered_or_panning: bool = ctx.hovered_window_id == w.id or w.touch_pan_active;
    const can_pan_y: bool = w.scroll_max_y > 0;
    const can_pan_x: bool = w.flags.horizontal_scrollbar and w.scroll_max_x > 0;
    if (hovered_or_panning and ctx.input.mouse_left_down and
        !w.flags.no_scrollbar and (can_pan_y or can_pan_x))
    {
        // Geometric test: was the press DOWN-EDGE inside this window's body
        // (below the title bar, within its rect)? We can't trust
        // `hovered_window_id` here: it is only ever SET to a window id, never
        // cleared, so it stays stale-pointing at the last-entered window when
        // the finger is over empty space. Combined with the press-edge
        // (mouse_left_clicked), this ties the gesture to where the finger
        // actually went down, so a press that starts outside the window (or in
        // empty space the stale hover still points here) can't hijack-scroll.
        const tbh_pan: f32 = effectiveTitleBarHeight(ctx, w);
        const mp_pan: Vec2 = ctx.input.mouse_pos;
        const press_in_body: bool = mp_pan[0] >= w.pos[0] and
            mp_pan[0] < w.pos[0] + w.size[0] and
            mp_pan[1] >= w.pos[1] + tbh_pan and
            mp_pan[1] < w.pos[1] + w.size[1];
        if (!w.touch_pan_active and ctx.active_id == 0 and
            ctx.input.mouse_left_clicked and press_in_body)
        {
            // Gesture start. Capture mouse + scroll origin so each subsequent
            // frame's delta is computed from a fixed base.
            w.touch_pan_active = true;
            w.touch_pan_start_mouse = ctx.input.mouse_pos;
            w.touch_pan_start_scroll_y = w.scroll_y;
            w.touch_pan_start_scroll_x = w.scroll_x;
        }
        if (w.touch_pan_active) {
            const dx: f32 = ctx.input.mouse_pos[0] - w.touch_pan_start_mouse[0];
            const dy: f32 = ctx.input.mouse_pos[1] - w.touch_pan_start_mouse[1];
            if (can_pan_y) {
                w.scroll_y = w.touch_pan_start_scroll_y - dy;
            }
            if (can_pan_x) {
                w.scroll_x = w.touch_pan_start_scroll_x - dx;
            }
        }
    } else {
        // Gesture end on release OR conditions invalidated.
        w.touch_pan_active = false;
    }

    // Mouse-wheel input: if this is the hovered window AND no inner
    // child already consumed the wheel, translate wheel ticks into
    // scroll_y. Wheel ticks are signed (positive = user pulled wheel
    // up = content should move down = scroll_y decreases). Multiplier
    // matches ImGui's default of ~5 lines per tick at the current
    // font size. After consuming, set `mouse_wheel_consumed` so any
    // OUTER scope (if this is itself somehow inside one) doesn't
    // double-apply - though for top-level windows the cascade ends
    // here.  Skipped if shift-pan already consumed the wheel this
    // frame (shift_pan_active sets `mouse_wheel_consumed` too).
    if (ctx.hovered_window_id == w.id and ctx.input.mouse_wheel_y != 0 and !ctx.mouse_wheel_consumed and
        !w.flags.no_scrollbar)
    {
        const wheel_speed: f32 = 5 * ctx.style.font_size;
        w.scroll_y -= ctx.input.mouse_wheel_y * wheel_speed;
        ctx.mouse_wheel_consumed = true;
    }
    // Clamp scroll_y to its valid range.
    w.scroll_y = @max(0, w.scroll_y);
    w.scroll_y = @min(w.scroll_y, w.scroll_max_y);

    // ---- X-axis bookkeeping + wheel ----------------
    // scroll_max_x mirrors scroll_max_y but only when the
    // horizontal_scrollbar flag is set. Without the flag, the
    // window stays at scroll_x = 0 and X-overflowing widgets get
    // clipped at the right edge (legacy behavior).
    if (w.flags.horizontal_scrollbar) {
        const viewport_w: f32 = @max(0, w.size[0] - 2 * ctx.style.window_padding[0]);
        w.scroll_max_x = @max(0, content_w_natural - viewport_w);
        // Wheel-X: same shape as wheel-Y above, gated separately so
        // a Y-consumed inner scope doesn't block X scrolling on the
        // outer (axes are independent). Also gated on `no_scrollbar`
        // to match the Y behavior — a single "no_scrollbar = true"
        // disables both directions.
        if (ctx.hovered_window_id == w.id and ctx.input.mouse_wheel_x != 0 and
            !ctx.mouse_wheel_x_consumed and !w.flags.no_scrollbar)
        {
            const wheel_speed: f32 = 5 * ctx.style.font_size;
            w.scroll_x += ctx.input.mouse_wheel_x * wheel_speed;
            ctx.mouse_wheel_x_consumed = true;
        }
        // Clamp scroll_x to its valid range.
        w.scroll_x = @max(0, w.scroll_x);
        w.scroll_x = @min(w.scroll_x, w.scroll_max_x);
    } else {
        // Flag not set — keep state in a clean reset position so a
        // later flag flip doesn't surface stale values.
        w.scroll_max_x = 0;
        w.scroll_x = 0;
    }

    // ---- Pop the content clip rect ------------------------------------
    if (!ctx.eager_mode) {
        w.draw_list.popClipRect(ctx.frame_arena.allocator());
    }

    // ---- Render ------------ ------------- scrollbar (after content, outside content clip) ------
    // P5.1: skipped when `no_scrollbar` is set. Wheel consumption
    // above is gated by the same flag — both must agree to fully
    // disable scrolling on this window.
    if (w.scroll_max_y > 0 and !w.flags.no_scrollbar) {
        renderScrollbar(ctx, w);
    }
    // P5.5b: horizontal scrollbar mirrors the vertical
    // logic — only renders when content overflows X AND the flag
    // opted in. `no_scrollbar` suppresses both axes.
    if (w.scroll_max_x > 0 and w.flags.horizontal_scrollbar and !w.flags.no_scrollbar) {
        renderScrollbarX(ctx, w);
    }

    // Pop window stack.
    _ = ctx.window_stack.pop();
    ctx.current_window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        null;

    // Restore current draw list to whichever window is now top of
    // stack (or null if no window is in scope). Skip in eager
    // mode - there's no draw list to restore.
    if (!ctx.eager_mode) {
        if (ctx.current_window) |outer| {
            ctx.current_draw_list = &outer.draw_list;
        } else {
            ctx.current_draw_list = null;
        }
    }
}

/// Returned by `ui.window(...)`. Tiny value type holding the
/// window pointer + the Ui handle so `close()` can run cleanup.
/// Caller defers `close()` to ensure the window stack pops even on
/// early returns.
pub const WindowHandle = struct {
    ui: Ui,
    win: *Window,

    pub fn close(self: WindowHandle) void {
        closeWindow(self.ui.ctx, self.win);
    }
};

/// Helper around `ctx.gpa.free(key)` that satisfies the type
/// system - `pending_persistence`'s key is `[]const u8` but we
/// allocated `[]u8`, so the free call needs `@constCast`. Kept
/// in one place so the cast doesn't proliferate.
fn gpa_free_key_safely(ctx: *UiContext, key: []const u8) void {
    ctx.gpa.free(@constCast(key));
}

/// Look up and remove the pending entry for `title`. Called by
/// `findOrCreateWindow`'s first-time branch BEFORE applying
/// `opts.initial_*` so persisted state takes precedence over the
/// caller's defaults. Returns null when no persisted entry
/// exists (new window, never seen before).
pub fn takeFor(ctx: *UiContext, title: []const u8) ?PersistedWindow {
    const entry: std.StringHashMapUnmanaged(PersistedWindow).KV =
        ctx.pending_persistence.fetchRemove(title) orelse return null;
    // The caller will copy out the fields it needs synchronously.
    // The key's memory is freed here; the value's `name` field
    // aliases the same memory we just freed, so the returned
    // struct's `name` is technically dangling - but the caller
    // doesn't read `name` (it already has the title). Document
    // this contract loudly:
    //   The returned `PersistedWindow.name` is dangling. Read
    //   `pos`, `size`, `scroll_y`, `user_resized` only.
    // Safer would be to keep the name alive for the duration of
    // the return - but that'd complicate the call site for no
    // real win (callers ignore `name` entirely).
    var result: PersistedWindow = entry.value;
    gpa_free_key_safely(ctx, entry.key);
    result.name = ""; // explicitly clobber to prevent UAF reads
    return result;
}

/// Clamp a window size to optional min / max bounds. Either
/// bound being `null` means "no limit on that side". Per-axis
/// independent.
fn clampWindowSize(
    size: Vec2,
    min: ?Vec2,
    max: ?Vec2,
) Vec2 {
    var out: @TypeOf(size) = size;
    if (min) |m| {
        if (out[0] < m[0]) {
            out[0] = m[0];
        }
        if (out[1] < m[1]) {
            out[1] = m[1];
        }
    }
    if (max) |m| {
        if (out[0] > m[0]) {
            out[0] = m[0];
        }
        if (out[1] > m[1]) {
            out[1] = m[1];
        }
    }
    return out;
}

pub fn findOrCreateWindow(
    ctx: *UiContext,
    title: []const u8,
    opts: WindowOpts,
) *Window {
    const id: Id = hashStr(0, title);
    if (ctx.windows.get(id)) |existing| {
        // docked windows pull pos/size from
        // their dock leaf every frame, overriding any setNextWindow*
        // overrides AND the stored fields. The leaf rect was just
        // refreshed by `dockSpaceImpl`'s `layoutSubtree` call earlier
        // this frame, so reading it here is consistent.
        // If the docked window's leaf has gone missing (the user
        // tore down the dockspace mid-session), we silently fall
        // back to the floating path by clearing `dock_node_id`
        // better than orphaning the window inside a dead tree.
        if (existing.dock_node_id) |node_id| {
            if (ctx.dock.lookup(node_id)) |leaf_node| {
                if (leaf_node.isLeaf()) {
                    existing.pos = leaf_node.pos;
                    existing.size = leaf_node.size;
                    existing.user_resized = true; // suppress auto-fit
                    return existing;
                }
            }
            existing.dock_node_id = null;
        }

        // Apply setNextWindow* overrides that fire every frame
        // (the *_always variants). Once-only variants fired only at
        // first-time creation below.
        if (ctx.next_window_pos) |p| {
            if (ctx.next_window_pos_always) {
                existing.pos = p;
            }
        }
        if (ctx.next_window_size) |s| {
            if (ctx.next_window_size_always) {
                existing.size = s;
                existing.user_resized = true; // explicit size disables auto-fit
            }
        }
        // P5.1: re-stamp flags from the per-frame opts so
        // callers can toggle chrome suppression between frames
        // (e.g. "lock window once user clicks Settle layout").
        // Preserves `is_child` which is set once at child creation
        // and shouldn't flip later.
        const was_child: bool = existing.flags.is_child;
        existing.flags = opts.flags;
        existing.flags.is_child = was_child;
        return existing;
    }

    // First-time submission for this title: allocate persistent state.
    const w: *Window = ctx.gpa.create(Window) catch unreachable; // OOM = we're toast anyway

    // persisted state takes precedence over
    // `opts.initial_*`. If a previous session saved this window's
    // pos/size, restore them before the explicit `setNextWindow*`
    // / `opts.initial_*` cascade. setNextWindow* still wins (it's
    // a runtime override - caller asked explicitly) - but in the
    // common case where the caller didn't set those, the persisted
    // values override the demo's hardcoded defaults.
    const persisted: ?PersistedWindow = takeFor(ctx, title);

    // setNextWindowPos overrides initial_pos; setNextWindowSize overrides
    // initial_size - both apply to first-creation regardless of
    // `_always` because `cond_once` is the most-common ImGui usage.
    const pos: Vec2 = ctx.next_window_pos orelse
        (if (persisted) |p| @as(Vec2, .{ p.pos[0], p.pos[1] }) else null) orelse
        opts.initial_pos orelse
        blk: {
            // Auto-tile: stagger new windows down-and-right by 30px
            // each so a fresh app doesn't pile every window at the
            // same screen origin. Advance the running counter
            // after capturing the position so the NEXT
            // not-positioned window starts 30px further along.
            const p: Vec2 = ctx.next_default_pos;
            ctx.next_default_pos = .{ p[0] + 30, p[1] + 30 };
            break :blk p;
        };
    var size: Vec2 = ctx.next_window_size orelse
        (if (persisted) |p|
            // P5.2: `always_auto_resize` overrides the persisted
            // size — caller asked for auto-fit-each-frame, so the
            // last save shouldn't pin a value the user explicitly
            // dragged to. initial_size still applies as the
            // first-frame starting point until content fills in.
            (if (opts.flags.always_auto_resize) null else @as(Vec2, .{ p.size[0], p.size[1] }))
        else
            null) orelse
        opts.initial_size;
    // Apply size constraints at creation. Same clamping is
    // re-applied per-frame in openWindow's consumer block so
    // user drag-resize is also bounded.
    size = clampWindowSize(size, ctx.next_window_size_min, ctx.next_window_size_max);
    w.* = .{
        .id = id,
        .pos = pos,
        .size = size,
        .scroll_y = if (persisted) |p| p.scroll_y else 0,
        .scroll_x = if (persisted) |p| p.scroll_x else 0,
        // Explicit size at creation time also disables auto-fit so a
        // 280×240 phase-4a window doesn't immediately grow to fit
        // overflow content - that's the whole point of giving it a size.
        // Persisted state implies the user already resized at some
        // point - preserve that signal so auto-fit stays disabled.
        .user_resized = (ctx.next_window_size != null) or (opts.initial_pos != null) or
            (if (persisted) |p| p.user_resized else false),
        // P5.1: propagate caller's chrome-suppression
        // flags onto the persistent window state. Re-stamped each
        // frame by the existing-window path above so the caller can
        // toggle flags between submissions.
        .flags = opts.flags,
    };
    const cap: usize = @min(title.len, w.name_buf.len);
    @memcpy(w.name_buf[0..cap], title[0..cap]);
    w.name_len = cap;

    ctx.windows.put(ctx.gpa, id, w) catch unreachable;
    return w;
}

///.ii: cumulative-motion threshold (in
/// canvas pixels) past which a title-bar drag becomes a
/// dock-drag operation - i.e. `DockContext.dragging_window` gets
/// set and the dock-target overlay becomes visible. Matches
/// imgui's default `MouseDragThreshold` (8 px). Measured as
/// chebyshev distance (`max(|dx|, |dy|)`) from the press point.
pub const dock_drag_threshold_px: f32 = 8.0;

fn renderWindowChrome(ctx: *UiContext, w: *Window) void {
    // ---- title-bar drag + corner resize
    // Hit-test the title bar: if the user mouse-presses inside it and
    // no other widget owns active_id, capture the drag. While we
    // own active_id, follow the mouse delta on `w.pos`. Symmetric
    // logic for the bottom-right corner grip drives `w.size`.
    // The drag and resize get DETERMINISTIC ids derived from the
    // window's id so they're unique across all windows + don't collide
    // with user widgets.
    const drag_id: Id = widgetId(ctx, w, "##drag_title");
    const resize_id: Id = widgetId(ctx, w, "##resize_grip");

    const title_rect = Rectangle{
        .x = w.pos[0],
        .y = w.pos[1],
        .width = w.size[0],
        .height = ctx.style.title_bar_height,
    };
    const grip_size: f32 = 14;
    const resize_rect = Rectangle{
        .x = w.pos[0] + w.size[0] - grip_size,
        .y = w.pos[1] + w.size[1] - grip_size,
        .width = grip_size,
        .height = grip_size,
    };
    const min_size: Vec2 = .{ 80, ctx.style.title_bar_height + 24 };

    const mp: Vec2 = ctx.input.mouse_pos;
    const md: bool = ctx.input.mouse_left_down;
    // Rising-edge signal: true only on the frame the user PRESSES.
    // imgui uses `IsMouseClicked(0)` for the same purpose at
    // `imgui.cpp` ~line 5524 (UpdateMouseMovingWindowNewFrame).  We
    // must NOT start a drag on "currently held" — that would let the
    // user click on canvas, hold, walk the cursor into a window's
    // title bar, and unintentionally pick up the window.  Bug was
    // reproducible by holding LMB on bare canvas then dragging the
    // cursor into the Physics panel header in `ball_physics`.
    const mc: bool = ctx.input.mouse_left_clicked;
    const mp_in_title: bool = pointInRect(mp, title_rect);
    const mp_in_grip: bool = pointInRect(mp, resize_rect);

    // ---- Title-bar drag ----------------------------------------------
    // Begin drag: mouse fresh-CLICKED inside title, no other widget
    // active.  P5.1: skipped when `no_move` is set OR when
    // `no_title_bar` is set (no title bar means no drag handle to
    // hit).
    const can_move: bool = !w.flags.no_move and !w.flags.no_title_bar;
    if (can_move and mc and ctx.active_id == 0 and mp_in_title and !mp_in_grip) {
        ctx.active_id = drag_id;
        ctx.active_id_just_activated = true;
        // Stash press-x as click-anchor offset within the title bar
        // so the window doesn't snap-to-mouse on first frame.
        ctx.active_id_press_x = mp[0] - w.pos[0];
        ctx.active_id_press_value = @floatCast(mp[1] - w.pos[1]);
        //.ii: also stash the raw press SCREEN position so
        // the cumulative-motion threshold can fire below. The
        // delta (mp - press_screen) measures how far the mouse has
        // travelled from where the user clicked, independent of
        // any clamping applied to `w.pos`.
        ctx.active_id_press_screen = mp;
        // Clicking a window focuses it.
        ctx.focused_window_id = w.id;
    }
    // Continue drag.
    if (ctx.active_id == drag_id) {
        if (md) {
            w.pos[0] = mp[0] - ctx.active_id_press_x;
            w.pos[1] = mp[1] - @as(f32, @floatCast(ctx.active_id_press_value));
            // Clamp to canvas so the title bar can't disappear off-screen.
            const cw: f32 = float(ctx.canvas_w);
            const ch: f32 = float(ctx.canvas_h);
            w.pos[0] = @max(w.pos[0], -w.size[0] + 32);
            w.pos[0] = @min(w.pos[0], cw - 32);
            w.pos[1] = @max(w.pos[1], 0);
            w.pos[1] = @min(w.pos[1], ch - ctx.style.title_bar_height);

            //.ii: dock-drag tracking is GATED
            // on a cumulative-motion threshold from the press
            // point. Without this gate, the dock-target overlay
            // would pop on every single click of a title bar - bad
            // UX (overlay flashes on simple focus clicks). Past
            // the threshold, we're confident the user intends to
            // drag, and the overlay (5.5d.iii) becomes useful.
            // 8 px matches imgui's default `MouseDragThreshold`.
            // Cumulative motion = max(|dx|, |dy|) - cheaper and
            // matches imgui's chebyshev metric.
            const dx: f32 = mp[0] - ctx.active_id_press_screen[0];
            const dy: f32 = mp[1] - ctx.active_id_press_screen[1];
            const motion: f32 = @max(@abs(dx), @abs(dy));
            if (motion >= dock_drag_threshold_px) {
                ctx.dock.dragging_window = w.id;
            }
        } else {
            ctx.active_id = 0;
            // Mouse released.iv: if we
            // were tracking a dock-drag AND the release landed
            // over one of the drop zones, enqueue the matching
            // DockRequest. `processRequests` (already called
            // by endFrame) drains the queue on this same frame
            // so the dock takes effect next frame.
            // The hit-test runs even when `dragging_window` is
            // null - that path means motion never crossed the
            // threshold, and `hitTestDockTargets` returns null
            // anyway because no overlay was visible.
            if (ctx.dock.dragging_window) |dragging_id| {
                if (hitTestDockTargets(ctx, mp)) |hit| {
                    // stash the dragged window's
                    // current pos/size as its pre-dock anchor IF
                    // it isn't already docked. This is the
                    // runtime drag-to-dock path; the programmatic
                    // path stashes in dockBuilderDockWindow.
                    // (We can't stash from processRequests itself
                    // - that lives in ui_dock.zig and doesn't
                    // know about Window's pre_dock_*. Doing it
                    // at enqueue time is the cleanest hook.)
                    if (ctx.windows.get(dragging_id)) |dragged| {
                        if (dragged.pre_dock_pos == null and dragged.dock_node_id == null) {
                            dragged.pre_dock_pos = dragged.pos;
                            dragged.pre_dock_size = dragged.size;
                        }
                    }
                    const req: DockRequest = switch (hit.kind) {
                        .center => .{ .dock_as_tab = .{
                            .target_node = hit.target_node,
                            .source_window = dragging_id,
                        } },
                        .top => .{ .dock_as_split = .{
                            .target_node = hit.target_node,
                            .source_window = dragging_id,
                            .dir = .top,
                        } },
                        .right => .{ .dock_as_split = .{
                            .target_node = hit.target_node,
                            .source_window = dragging_id,
                            .dir = .right,
                        } },
                        .bottom => .{ .dock_as_split = .{
                            .target_node = hit.target_node,
                            .source_window = dragging_id,
                            .dir = .bottom,
                        } },
                        .left => .{ .dock_as_split = .{
                            .target_node = hit.target_node,
                            .source_window = dragging_id,
                            .dir = .left,
                        } },
                    };
                    ctx.dock.pending_requests.append(ctx.gpa, req) catch {};
                }
                ctx.dock.dragging_window = null;
            }
        }
    }

    // ---- Corner-grip resize ------------------------------------------
    // Begin resize: mouse fresh-CLICKED inside corner grip, no other
    // active widget.  Same rising-edge rule as the title-bar drag
    // above — a held-LMB cursor entering the grip should NOT pick
    // up a resize.  P5.1: gated on `no_resize`.
    if (!w.flags.no_resize and mc and ctx.active_id == 0 and mp_in_grip) {
        ctx.active_id = resize_id;
        ctx.active_id_just_activated = true;
        ctx.active_id_press_x = mp[0] - (w.pos[0] + w.size[0]);
        ctx.active_id_press_value = @floatCast(mp[1] - (w.pos[1] + w.size[1]));
        ctx.focused_window_id = w.id;
    }
    if (!w.flags.no_resize and ctx.active_id == resize_id) {
        if (md) {
            const new_w: f32 = mp[0] - w.pos[0] - ctx.active_id_press_x;
            const new_h: f32 = mp[1] - w.pos[1] - @as(f32, @floatCast(ctx.active_id_press_value));
            w.size[0] = @max(new_w, min_size[0]);
            w.size[1] = @max(new_h, min_size[1]);
            w.user_resized = true; // disables future auto-fit grow
        } else {
            ctx.active_id = 0;
        }
    }

    // ---- Background
    // Outer background (everything below the title bar).
    // P5.1: skipped when `no_background` is set — useful for HUD-style
    // overlays where caller provides their own backdrop.
    if (!w.flags.no_background) {
        drawRectFilled(
            ctx,
            .{ .x = w.pos[0], .y = w.pos[1], .width = w.size[0], .height = w.size[1] },
            Color.toWire(ctx.style.window_bg),
        );
    }

    // Title bar - colour reflects focus state. Focused windows draw
    // with `title_bg_active`; unfocused windows use `title_bg`.
    // P5.1: entire title-bar block (rect + text) skipped when
    // `no_title_bar` is set.
    if (!w.flags.no_title_bar) {
        const title_col: Color = if (ctx.focused_window_id == w.id)
            ctx.style.title_bg_active
        else
            ctx.style.title_bg;
        drawRectFilled(
            ctx,
            .{ .x = w.pos[0], .y = w.pos[1], .width = w.size[0], .height = ctx.style.title_bar_height },
            Color.toWire(title_col),
        );
        // Title text - center vertically inside the title bar.
        // P5.5a: when `unsaved_document` is set, render
        // a " *" marker after the title. Format into a stack buffer
        // sized for the max name length (128) + " *\0" suffix.
        const title_y: f32 = w.pos[1] + (ctx.style.title_bar_height - ctx.style.font_size) / 2;
        if (w.flags.unsaved_document) {
            var name_buf: [132]u8 = undefined;
            const formatted: []const u8 = bufPrint(
                &name_buf,
                "{s} *",
                .{w.name()},
            ) catch w.name(); // overflow → fall back to plain name (no marker)
            drawTextAtS(
                ctx,
                .{ w.pos[0] + 8, title_y },
                formatted,
                Color.toWire(ctx.style.text),
            );
        } else {
            drawTextAtS(
                ctx,
                .{ w.pos[0] + 8, title_y },
                w.name(),
                Color.toWire(ctx.style.text),
            );
        }
    }

    // ---- Resize-grip glyph (small triangle in bottom-right) ----------
    // Render only if window is interactive (not eager-mode in an RT scope).
    // The grip is two filled triangles forming a corner.
    // P5.1: skipped when `no_resize` is set.
    if (!w.flags.no_resize) {
        const grip_col: Color = if (ctx.active_id == resize_id or
            (ctx.hovered_window_id == w.id and mp_in_grip))
            ctx.style.button_active
        else
            ctx.style.border;
        drawRectFilled(
            ctx,
            .{
                .x = resize_rect.x + grip_size - 4,
                .y = resize_rect.y + grip_size - 8,
                .width = 3,
                .height = 3,
            },
            Color.toWire(grip_col),
        );
        drawRectFilled(
            ctx,
            .{
                .x = resize_rect.x + grip_size - 8,
                .y = resize_rect.y + grip_size - 4,
                .width = 3,
                .height = 3,
            },
            Color.toWire(grip_col),
        );
        drawRectFilled(
            ctx,
            .{
                .x = resize_rect.x + grip_size - 4,
                .y = resize_rect.y + grip_size - 4,
                .width = 3,
                .height = 3,
            },
            Color.toWire(grip_col),
        );
    }

    // Border.
    drawRect(
        ctx,
        .{ .x = w.pos[0], .y = w.pos[1], .width = w.size[0], .height = w.size[1] },
        Color.toWire(ctx.style.border),
    );
}

fn openWindow(
    ctx: *UiContext,
    title: []const u8,
    opts: WindowOpts,
) ?WindowHandle {
    const w: *Window = findOrCreateWindow(ctx, title, opts);
    w.last_frame_active = ctx.frame_count;

    // docked-window gating. When a window is
    // assigned to a dock leaf, only the leaf's currently-selected
    // window submits this frame - every other docked-but-inactive
    // tab returns null so the caller's `if (u.window(...)) |w| {}`
    // block is skipped (widgets don't submit, the draw list isn't
    // added to `frame_windows`, no chrome is rendered).
    // Selected docked windows DO continue through openWindow; they
    // skip chrome rendering below (the leaf's tab bar plays the
    // role of the title bar) but otherwise behave like floating
    // windows - content area, scroll, clip rect, etc. all unchanged.
    const docked: bool = w.dock_node_id != null;
    if (docked) {
        const node_id: Id = w.dock_node_id.?;
        if (ctx.dock.lookup(node_id)) |node| {
            if (node.leaf) |leaf| {
                if (leaf.selected_window_id != w.id) {
                    // Inactive tab. Stamp the frame counter so
                    // window GC doesn't drop us; everything else
                    // skipped. The leaf's tab bar (rendered by
                    // dockSpaceImpl) handles tab-click selection.
                    return null;
                }
            }
        }
    }

    // NOTE: the per-frame layout reset (content cursor) is computed AFTER
    // renderWindowChrome below — the chrome's title-bar drag updates w.pos this
    // frame, and the content must use that SAME post-drag pos or the text lags
    // the window chrome by one frame while dragging.

    // Push onto the window stack so `current_window` is correct
    // for any widget calls before close().
    ctx.window_stack.append(w) catch {
        return null;
    };
    ctx.current_window = w;

    // ---- Draw-list bookkeeping
    // Each window owns its own draw list, recorded fresh every
    // frame. We append to the frame's render order list so endFrame
    // knows what to play back. In eager mode (inside an RT scope)
    // we DON'T append - eager submissions render synchronously, no
    // need for endFrame replay.
    if (!ctx.eager_mode) {
        w.draw_list.clear();
        ctx.frame_windows.append(w) catch {};
        ctx.current_draw_list = &w.draw_list;
    }

    // Render window chrome (title bar, border, resize grip). Done
    // BEFORE the content clip push so chrome isn't clipped.
    // docked windows skip chrome - their
    // identity + selection lives in the leaf's tab bar (rendered
    // by `dockSpaceImpl`), and the leaf's parent split provides
    // the resize affordance (splitter drag, later step). The
    // title-bar-height SPACE is still reserved by the inner_origin
    // computation above; the leaf's tab bar paints in that area
    // from outside the window's draw list.
    if (!docked) {
        renderWindowChrome(ctx, w);
    }

    // Reset per-frame layout state. Cursor starts inside the title
    // bar's bottom edge plus window padding. Scroll-aware: when
    // `scroll_y > 0`, content renders shifted UP by that amount
    // i.e. the cursor's initial y is moved UP so widgets that would
    // have appeared at the natural inner_origin now sit ABOVE the
    // visible content area (and get clipped by the content clip rect
    // we push below).
    const inner_origin: Vec2 = .{
        w.pos[0] + ctx.style.window_padding[0],
        w.pos[1] + effectiveTitleBarHeight(ctx, w) + ctx.style.window_padding[1],
    };
    const inner_max: Vec2 = .{
        w.pos[0] + w.size[0] - ctx.style.window_padding[0],
        w.pos[1] + w.size[1] - ctx.style.window_padding[1],
    };
    // Reset the whole LayoutScope each frame. Origin = inner top-
    // left; work_rect_max = inner bottom-right (the right + bottom
    // edges of the content area, used by `getContentRegionAvail`
    // and `beginChild` auto-fill); cursor + cursor_max = inner
    // top-left (modulo scroll); cursor_pos_prev_line seeded to the
    // same so a pathological `sameLine` call before any widget
    // submission lands at a sensible position rather than (0,0).
    // All other fields zero.
    w.layout = .{
        .origin = inner_origin,
        .work_rect_max = inner_max,
        .cursor_pos = .{ inner_origin[0] - w.scroll_x, inner_origin[1] - w.scroll_y },
        .cursor_max = .{ inner_origin[0] - w.scroll_x, inner_origin[1] - w.scroll_y },
        .cursor_pos_prev_line = inner_origin,
    };

    // ---- Push the content-area clip rect
    // Content lives below the title bar and inside the window border.
    // Pushing this clip rect ensures scrolled-out widgets (those
    // shifted above or below the visible content) get scissored away
    // at GL replay. Popped in closeWindow before the scrollbar
    // renders (the scrollbar lives outside the content clip).
    if (!ctx.eager_mode) {
        // Use effectiveTitleBarHeight (0 when no_title_bar=true) to
        // match the cursor's inner_origin calculation above. Earlier
        // versions hard-coded ctx.style.title_bar_height here, which
        // meant borderless windows (no_title_bar=true) clipped away
        // the top N pixels of their own content — the cursor started
        // at y=window_padding[1] while the clip rect started at
        // y=title_bar_height, so anything between the two got
        // scissored off. Visible symptom: tab bars and other widgets
        // submitted as the first thing inside the window appeared
        // truncated/squished. See screenshots/turn-411-tab-clip.png.
        const tbh: f32 = effectiveTitleBarHeight(ctx, w);
        // Clamp to non-negative.  A window whose outer height is
        // less than the title-bar height (e.g. a brand-new docked
        // window on the frame its dock leaf hasn't been sized yet,
        // or a user-set 0×0 initial size) would otherwise produce
        // `content_clip.height < 0`, which crashes WebGL's
        // `glScissor` later at draw-list replay time.  A 0-size
        // clip is harmless (no pixels pass the test); a negative
        // one is a GL invariant violation.
        const content_clip: Rectangle = .{
            .x = w.pos[0],
            .y = w.pos[1] + tbh,
            .width = @max(0, w.size[0]),
            .height = @max(0, w.size[1] - tbh),
        };
        w.draw_list.pushClipRect(ctx.frame_arena.allocator(), content_clip);
    }

    // ---- setNextWindow* consumption + focus / hover
    // `setNextWindowFocus()` raises this window to top-of-z by
    // updating the focused id. Mouse-down on a window also
    // refocuses it (handled in renderWindowChrome's title-bar drag).
    if (ctx.next_window_focus) {
        ctx.focused_window_id = w.id;
    }
    // Hover detection: walk the mouse position; if it's inside this
    // window's outer rect AND no later (higher-z) window claims it,
    // record us as hovered. We update unconditionally - submission
    // order is back-to-front, so the LAST window covering the mouse
    // wins (matches ImGui).
    // P5.4: when `no_mouse_inputs` or `no_inputs` is set, this
    // window stays passthrough — skip the overwrite so any earlier
    // (deeper) covered window keeps the hover. All downstream
    // interaction gates test `hovered_window_id == w.id`, so this
    // single skip is enough to make the window mechanically
    // see-through for mouse input.
    const mp: Vec2 = ctx.input.mouse_pos;
    const passthrough_mouse: bool = w.flags.no_mouse_inputs or w.flags.no_inputs;
    if (!passthrough_mouse and
        mp[0] >= w.pos[0] and mp[0] < w.pos[0] + w.size[0] and
        mp[1] >= w.pos[1] and mp[1] < w.pos[1] + w.size[1])
    {
        ctx.hovered_window_id = w.id;
    }
    // Reset for next caller.
    ctx.next_window_pos = null;
    ctx.next_window_size = null;
    ctx.next_window_bg_alpha = null;
    ctx.next_window_focus = false;
    ctx.next_window_pos_always = true;
    ctx.next_window_size_always = true;
    // Size constraints clamp the existing window size on every
    // frame they're set, then clear so they don't bleed into
    // unrelated subsequent windows. Imgui keeps these
    // associated with the window (so they persist across
    // frames) - we follow the simpler "per-call" semantics:
    // user must call setNextWindowSizeConstraints each frame
    // they want the bounds enforced. Document if/when this
    // turns out to be a footgun.
    if (ctx.next_window_size_min != null or ctx.next_window_size_max != null) {
        w.size = clampWindowSize(w.size, ctx.next_window_size_min, ctx.next_window_size_max);
        ctx.next_window_size_min = null;
        ctx.next_window_size_max = null;
    }

    return WindowHandle{ .ui = .{ .ctx = ctx }, .win = w };
}

/// Flags for `beginChild`. Mirrors imgui's `ImGuiChildFlags`
/// (10 fields per imgui 1.92.8). Struct-of-bools per Zig idiom
/// each flag independently type-checked at the call site.
/// - replaces the older `ChildOpts {border,
/// padding}`. `padding` was a zimr-specific field that doesn't
/// map to imgui's convention (padding comes from
/// `style.window_padding` when the relevant flags are set).
/// **Current implementation status:** `border` and
/// `always_use_window_padding` are wired and active. The
/// resize/auto-resize/frame_style/nav_flattened flags are plumbed
/// as no-op-today - user code can set them now and pick up the
/// behavior automatically when each lands. Filed per-flag below.
pub const ChildFlags = struct {
    /// Show an outer 1px border around the child's outer rect.
    /// Implicitly enables `always_use_window_padding` semantics
    /// (border without inner padding looks cramped). Imgui:
    /// `ImGuiChildFlags_Borders`.
    border: bool = false,
    /// Pad inside the child with `style.window_padding` even if no
    /// border is drawn. Default false - non-bordered children
    /// start their layout at the child's top-left (matches imgui's
    /// `ImGuiChildFlags_None`). Set this when you want a tight
    /// border-less panel with breathing room inside. Imgui:
    /// `ImGuiChildFlags_AlwaysUseWindowPadding`.
    always_use_window_padding: bool = false,
    /// Allow the user to resize the child by dragging its right
    /// edge. **NOT YET IMPLEMENTED** - landed when child-edge
    /// drag handles are wired (filed for follow-up). Imgui:
    /// `ImGuiChildFlags_ResizeX`.
    resize_x: bool = false,
    /// Allow resize from the bottom edge. **NOT YET IMPLEMENTED.**
    /// Imgui: `ImGuiChildFlags_ResizeY`.
    resize_y: bool = false,
    /// Auto-size the child's width to its content. **NOT YET
    /// IMPLEMENTED** - needs two-pass measurement. Imgui:
    /// `ImGuiChildFlags_AutoResizeX`.
    auto_resize_x: bool = false,
    /// Auto-size the child's height to its content. **NOT YET
    /// IMPLEMENTED.** Imgui: `ImGuiChildFlags_AutoResizeY`.
    auto_resize_y: bool = false,
    /// Force measurement even when the child is hidden / clipped.
    /// **NOT YET IMPLEMENTED.** Imgui: `ImGuiChildFlags_AlwaysAutoResize`.
    always_auto_resize: bool = false,
    /// Render the child like a framed item - uses `frame_bg`,
    /// `frame_padding`, `frame_rounding` instead of the window
    /// variants. Same shape as a `listBox` frame. **NOT YET
    /// IMPLEMENTED** as a flag on beginChild - `listBox` has its
    /// own framing path that does this already. Imgui:
    /// `ImGuiChildFlags_FrameStyle`.
    frame_style: bool = false,
    /// Share focus scope with the parent; let keyboard nav cross
    /// child borders. **NOT YET IMPLEMENTED** - needs keyboard
    /// nav infrastructure (+). Imgui:
    /// `ImGuiChildFlags_NavFlattened`.
    nav_flattened: bool = false,
};

fn advanceLayout(
    w: *Window,
    consumed: Vec2,
    item_spacing: Vec2,
    window_pad_x: f32,
) void {
    _ = window_pad_x;
    const s: *LayoutScope = &w.layout;
    const placed_at: Vec2 = s.cursor_pos;
    const right: f32 = placed_at[0] + consumed[0];
    const bottom: f32 = placed_at[1] + consumed[1];

    // Track laid-out bottom-right for autosize / scroll_max.
    if (right > s.cursor_max[0]) {
        s.cursor_max[0] = right;
    }
    if (bottom > s.cursor_max[1]) {
        s.cursor_max[1] = bottom;
    }

    // Accumulate this row's tallest widget.
    if (consumed[1] > s.line_height) {
        s.line_height = consumed[1];
    }

    // Record where THIS item sits, for any subsequent `sameLine`
    // call. X = right edge (so the next sameLine starts there);
    // Y = top of the row (so cursor.y restores correctly).
    s.cursor_pos_prev_line = .{ right, placed_at[1] };

    // Promote current row tracking to "previous" so a sameLine
    // call after this advance keeps the right line_height.
    s.prev_line_height = s.line_height;

    // Advance cursor to the next row. X resets to origin+indent
    // minus scroll_x (imgui's `window.Pos.x - window.Scroll.x +
    // local_x` formula — origin already holds `Pos.x + padding`,
    // so subtract scroll here to get the screen-space cursor for
    // the next line's first widget).  Y drops past the bottom of
    // the tallest widget on this row; scroll_y stays baked in
    // because cursor_pos.y advances by additions, never resets.
    s.cursor_pos[0] = s.origin[0] - w.scroll_x + s.indent_x;
    s.cursor_pos[1] = placed_at[1] + s.line_height + item_spacing[1];

    // Current-row accumulator resets - `sameLine` would re-seed
    // it from `prev_line_height` if it fires before another widget.
    s.line_height = 0;
}

/// `endChild` core - pop the child Window, advance the parent's
/// layout past it, and record the child's outer rect as the
/// parent's "last item" so `isItemHovered()` after `endChild` asks
/// "was the user hovering the child's outer rect?" - useful for
/// scrollbar future-work and drag-and-drop target detection.
/// Parent's `current_window` restoration is by popping the window
/// stack; there's no state to save/restore on the parent because
/// the parent's `layout` was never touched during the child's
/// scope (the whole point of the child-as-Window refactor).
fn endChildImpl(ctx: *UiContext) void {
    const child: *Window = ctx.current_window orelse return;
    // Safety: if `endChild` is called without a matching
    // `beginChild` (e.g. due to early return inside the block),
    // current_window might not be a child - bail gracefully.
    if (!child.flags.is_child) {
        return;
    }

    // ---- Compute scroll bookkeeping
    // `cursor_max[1]` was tracked in scrolled coordinates (because
    // beginChild seeded the cursor at `inner_origin - scroll_y`), so
    // add `scroll_y` back to recover the un-shifted natural extent.
    // `viewport_h` is the visible content height - the distance
    // between `origin[1]` (top of content area) and
    // `work_rect_max[1]` (bottom of content area).
    const inner_origin_y: f32 = child.layout.origin[1];
    const content_h_natural: f32 = child.layout.cursor_max[1] + child.scroll_y - inner_origin_y;
    const viewport_h: f32 = @max(0, child.layout.work_rect_max[1] - inner_origin_y);
    child.scroll_max_y = @max(0, content_h_natural - viewport_h);

    // Capture the child's outer rect for hit-tests + later layout
    // advance. `pos` + `size` are the rect set by beginChild.
    const child_rect: Rectangle = .{
        .x = child.pos[0],
        .y = child.pos[1],
        .width = child.size[0],
        .height = child.size[1],
    };

    // Mouse-wheel input: if the cursor is inside this child's outer
    // rect AND no inner scope already claimed the wheel this frame,
    // translate wheel ticks into scroll_y. Inner children's
    // `endChildImpl` runs before their parent's (nesting order), so
    // the deepest scope under the pointer gets first dibs - that's
    // why we check `mouse_wheel_consumed` and set it after applying.
    if (!ctx.mouse_wheel_consumed and ctx.input.mouse_wheel_y != 0 and pointInRect(ctx.input.mouse_pos, child_rect)) {
        const wheel_speed: f32 = 5 * ctx.style.font_size;
        child.scroll_y -= ctx.input.mouse_wheel_y * wheel_speed;
        ctx.mouse_wheel_consumed = true;
    }
    // Clamp scroll_y to its valid range.
    child.scroll_y = @max(0, @min(child.scroll_y, child.scroll_max_y));

    // ---- Pop the content clip + render scrollbar -------------------
    // Order matters: pop the clip BEFORE rendering the scrollbar so
    // the scrollbar isn't clipped by the child's own viewport. The
    // scrollbar still ends up inside the parent's clip (it's drawn
    // before we pop the window stack, while `current_window` is
    // still the child).
    if (!ctx.eager_mode) {
        if (ctx.current_draw_list) |dl| {
            dl.popClipRect(ctx.frame_arena.allocator());
        }
    }
    if (child.scroll_max_y > 0) {
        renderScrollbar(ctx, child);
    }

    // ---- Pop id_stack + window_stack ------------------------------
    _ = ctx.id_stack.pop();
    _ = ctx.window_stack.pop();
    const parent: *Window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        return; // malformed nesting - parent already popped
    ctx.current_window = parent;

    // Record the child as the parent's "last item." The child
    // doesn't take an interaction id (children themselves aren't
    // clickable widgets - items INSIDE them are), so `last_item_id`
    // is zero. `last_item_hovered` reflects pointer-in-child-rect
    // so `isItemHovered` works after endChild.
    parent.layout.last_item_id = 0;
    parent.layout.last_item_rect = child_rect;
    parent.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, child_rect);

    // Advance the parent's cursor past the child's outer rect.
    // This is what makes `sameLine` after `endChild` land beside
    // the child (it sets cursor_pos_prev_line to the child's right
    // edge + row top) and what makes the next non-sameLine widget
    // sit BELOW the child.
    advanceLayout(
        parent,
        .{ child_rect.width, child_rect.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
}

/// Options for `Ui.dockSpace`. Today only sized for forward-
/// compat; will grow `flags: DockNodeFlags` for the imgui-parity
/// behavior toggles (passthru-central-node, no-split, no-resize,
/// no-tab-bar, etc).
pub const DockSpaceOpts = struct {
    /// Optional override of the dockspace background color. Null
    /// inherits from `style`.
    bg_color: ?Color = null,
};

/// Returns where the next widget will be placed. In the new
/// model, `cursor_pos` always reflects the resolved position
/// (sameLine writes directly to it), so this is just a read.
/// Kept as a function for callsite readability + a single place
/// to revisit if we re-introduce deferred-resolve semantics.
/// The `spacing_x` argument is no longer used (was needed for the
/// old pending-same-line branch that computed
/// `cursor_max[0] + spacing` lazily). Retained in the signature
/// to keep the existing ~30 callsites compiling; the unused-param
/// suppression makes the intent explicit.
fn resolveCursor(w: *Window, _: f32) Vec2 {
    return w.layout.cursor_pos;
}

fn drawRectLines(
    ctx: *UiContext,
    r: Rectangle,
    color: Color,
) void {
    // Hollow rectangle = 4 thin filled rectangles around the edges.
    // 1px thickness picks up cleanly on the LCD without antialiasing.
    const thickness: f32 = 1;
    const c: ColorU32 = Color.toWire(color);
    drawRect(ctx, .{ .x = r.x, .y = r.y, .width = r.width, .height = thickness }, c);
    drawRect(ctx, .{ .x = r.x, .y = r.y + r.height - thickness, .width = r.width, .height = thickness }, c);
    drawRect(ctx, .{ .x = r.x, .y = r.y, .width = thickness, .height = r.height }, c);
    drawRect(ctx, .{ .x = r.x + r.width - thickness, .y = r.y, .width = thickness, .height = r.height }, c);
}

/// Internal: register/resolve the dockspace root + render the
/// outline placeholder. Mutates layout (advances the cursor by
/// `size`) and `ctx.dock` (creates root node if first call;
/// pushes the id to `dockspaces_this_frame`).
fn dockSpaceImpl(
    ctx: *UiContext,
    str_id: []const u8,
    size: Vec2,
    opts: DockSpaceOpts,
) Id {
    const w: *Window = ctx.current_window orelse return 0;

    // Derive a stable id by hashing the str_id under the window's
    // id stack - same convention as widgetId for all other widgets.
    // The result fits in `Id` (u32 alias of `Id`).
    const id: Id = widgetId(ctx, w, str_id);

    // Reserve layout space. Resolve any pending sameLine; we
    // want the dockspace at the current row's leading edge.
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = @max(0, size[0]),
        .height = @max(0, size[1]),
    };

    // if there's a pending dock tree
    // staged from persistence, try to restore it BEFORE we
    // fall through to the fresh-root creation path. Restore
    // is one-shot - it consumes the whole pending payload at
    // once. If the persisted tree contains a root matching
    // this dockspace's id, the user's subsequent builder
    // calls (`dockBuilderSplitNode`, etc.) become safe no-ops
    // (split target is no longer a leaf → rejected with
    // a/b = 0; user's `if (split.a != 0)` skips).
    if (ctx.pending_dock_tree.len > 0) {
        _ = tryRestoreDockTree(ctx.gpa, ctx);
    }

    // Find or create the root DockNode for this dockspace. IDs
    // are stable per-(window, str_id) so calling dockSpace with
    // the same arguments every frame reaches the same root.
    if (ctx.dock.lookup(id) == null) {
        // First-time: allocate a fresh leaf as the root. Pre-set
        // the id slot so `createNode` doesn't claim a fresh
        // `next_node_id` we don't want (we want the widget-hash
        // id, not a synthesized one).
        const node: *DockNode = ctx.gpa.create(DockNode) catch return 0;
        node.* = .{
            .id = id,
            .parent_id = null,
            .leaf = .{},
            // mark this node as a dockspace root.
            // Persistence (5.5k) uses this to distinguish trees
            // owned by a `dockSpace()` call from freestanding
            // node trees (which we don't create today, but the
            // bit reserves the distinction).
            .flags = .{ .is_dockspace = true },
        };
        ctx.dock.nodes.put(ctx.gpa, id, node) catch {
            ctx.gpa.destroy(node);
            return 0;
        };
    }

    // Push to per-frame dockspaces list for drag-drop hit-test
    // discovery in 5.5e.
    ctx.dock.dockspaces_this_frame.append(ctx.gpa, id) catch {};

    // Compute layout of the entire subtree rooted here. This
    // walks every node and writes per-node pos/size; the actual
    // rendering of leaf content happens via the per-window
    // submission path in `openWindow` - docked windows pull their
    // rect from the leaf, suppress their own chrome, and the
    // leaf's tab bar (emitted below) handles selection.
    layoutSubtree(&ctx.dock, id, .{ rect.x, rect.y }, .{ rect.width, rect.height });

    // ---- Placeholder rendering (5.5b) -------------------------------
    // A subtle 1px outline + corner hint label so the user can
    // see the dockspace is alive even before any windows dock in.
    const bg: Color = opts.bg_color orelse .{ .r = 20, .g = 30, .b = 50, .a = 60 };
    drawRectFilled(ctx, rect, Color.toWire(bg));
    drawRectLines(ctx, rect, .{ .r = 100, .g = 110, .b = 140, .a = 120 });

    // ---- tab-bar rendering DEFERRED
    // Originally tabs rendered inline here, but that meant the
    // FIRST frame after a `dockBuilder*` block saw empty leaves
    // (the builder runs AFTER `dockSpace()` returns). Deferring
    // tab-bar emission to endFrame fixes the ordering: by then
    // every builder call AND every docked-window submission has
    // run, so the tree is in its final per-frame state.
    // Tabs render into `foreground_dl` so they overlay docked
    // window content (which submits into per-window draw lists).
    // The id is added to `dockspaces_this_frame` already (above)
    // - endFrame walks that list to find dockspaces to draw.

    // Advance the parent cursor past the dockspace rect. Same
    // pattern as `beginChild` after content rendering completes.
    advanceLayout(
        w,
        .{ rect.width, rect.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );

    return id;
}

pub const SplitResult = struct { a: Id, b: Id };

/// per-node constraints. Flat packed
/// struct(u16), no Shared/Local/Local-in-Windows tier system
/// JUSTIFIED divergence from imgui (`ImGuiDockNodeFlags_`,
/// imgui.h:1533 + imgui_internal.h:1988). imgui's three-tier
/// system exists because it evolved the docking branch
/// incrementally on top of a `WindowClass`-typed restriction
/// system; we don't have window classes, so a flat single
/// bitset suffices.
/// Which imgui flags map to which zimr field:
///   imgui | zimr
///   -------------------------------------|---------------------
///   NoDockingSplit / NoSplit | `no_split`
///   NoTabBar | `no_tab_bar`
///   HiddenTabBar | `hidden_tab_bar`
///   NoResize / NoResizeX / NoResizeY | `no_resize`
///   NoDockingOverMe | `no_docking_over_me`
///   NoCloseButton | `no_close_button`
///   PassthruCentralNode | `passthru_central`
///   CentralNode | `is_central`
///   DockSpace | `is_dockspace`
/// Not modeled (out of scope or deferred):
///   NoWindowMenuButton (we don't have a window menu)
///   AutoHideTabBar (UX nicety, low priority)
///   KeepAliveOnly (matters if dockspaces in tabs)
///   NoUndocking (we don't ship undock-protect yet)
///   NoDockingSplitOther (we don't have "split other")
///   NoDockingOverOther (we don't have "over other")
///   NoDockingOverEmpty (we don't have "over empty")
pub const DockNodeFlags = packed struct(u16) {
    /// Prevent the user from splitting this node. The center
    /// (dock-as-tab) zone still works on this node's leaves; the
    /// top/right/bottom/left split zones are hidden + skipped in
    /// hit-test.
    no_split: bool = false,

    /// Tab bar is completely unavailable. No triangle in the
    /// corner to toggle it back. Useful for single-window panels
    /// where the tab bar would be visual noise.
    no_tab_bar: bool = false,

    /// Tab bar is hidden but conceptually present. Distinguished
    /// from `no_tab_bar` only for future toggle UX (a small
    /// indicator that brings the tab bar back). For now both
    /// flags hide the bar identically.
    hidden_tab_bar: bool = false,

    /// Disable splitter resize on this node (5.5i - not yet
    /// implemented; flag defined for forward compatibility).
    no_resize: bool = false,

    /// Refuse incoming docks onto this leaf. Both tab-merge
    /// (center zone) and side-splits are blocked. Hides the
    /// overlay entirely for this leaf.
    no_docking_over_me: bool = false,

    /// Hide per-tab close buttons (5.5j - not yet implemented).
    no_close_button: bool = false,

    /// Root-level: render `WindowBg` background everywhere except
    /// the central node; let inputs pass through the central node
    /// when empty. (5.5g - not yet implemented; flag defined.)
    passthru_central: bool = false,

    /// Marker: this is the "central" node of its dockspace.
    /// Central nodes stay visible when empty and absorb remaining
    /// space from siblings. At most one per dockspace.
    /// (5.5g - central-node layout logic uses this.)
    is_central: bool = false,

    /// Marker: this node is the root of a dockspace (set by
    /// `dockSpace()`). Floating multi-tab nodes (if we ever
    /// support them) would have this cleared. Helps
    /// `dockBuilderFinish` find the root and helps persistence
    /// distinguish trees that belong to a dockspace from any
    /// freestanding nodes.
    is_dockspace: bool = false,

    _padding: u7 = 0,

    /// Mask of flags that transfer from a leaf to its inheritor
    /// when that leaf is split (e.g. dock-as-split). The new
    /// "fresh" child gets default flags; the inheritor (the child
    /// that kept the existing content) keeps these.
    /// Mirrors imgui's `LocalFlagsTransferMask_`
    /// (imgui_internal.h:2016) - leaf-affinity flags transfer;
    /// tree-structural flags (`is_dockspace`, `passthru_central`)
    /// don't.
    pub const transfer_mask: DockNodeFlags = .{
        .no_split = true,
        .no_tab_bar = true,
        .hidden_tab_bar = true,
        .no_resize = true,
        .no_docking_over_me = true,
        .no_close_button = true,
        .is_central = true,
    };

    /// Return only the bits set in BOTH `self` and `mask`.
    /// Used for the inheritor-transfer pattern above.
    pub fn intersect(self: DockNodeFlags, mask: DockNodeFlags) DockNodeFlags {
        const a: u16 = @bitCast(self);
        const b: u16 = @bitCast(mask);
        return @bitCast(a & b);
    }

    /// Return `self` with every bit in `mask` cleared.
    pub fn except(self: DockNodeFlags, mask: DockNodeFlags) DockNodeFlags {
        const a: u16 = @bitCast(self);
        const b: u16 = @bitCast(mask);
        return @bitCast(a & ~b);
    }

    /// Bitwise OR of two flag sets. Used by the collapse paths to fold a
    /// surviving sibling's leaf-affinity flags into the parent slot while
    /// keeping the parent's structural bits.
    pub fn union_(self: DockNodeFlags, other: DockNodeFlags) DockNodeFlags {
        const a: u16 = @bitCast(self);
        const b: u16 = @bitCast(other);
        return @bitCast(a | b);
    }
};

/// Internal: an empty leaf with a parent SPLIT collapses away
/// the sibling node "absorbs" the parent's identity. The parent
/// node's id stays valid but its role changes to match the
/// sibling (split → split, leaf → leaf), preserving any
/// outstanding references to the parent id.
/// Steps:
///   1. Identify the sibling (the parent's other child).
///   2. Copy the sibling's role into the parent (split/leaf data).
///   3. If sibling was a split, re-parent its children to the
///      (now-rewritten) parent.
///   4. Free the original (empty) leaf and the sibling node.
fn collapseEmptyLeaf(
    ctx: *DockContext,
    gpa: Allocator,
    empty_leaf: *DockNode,
    parent_id: Id,
) void {
    const parent: *DockNode = ctx.lookup(parent_id) orelse return;
    const split: DockNode.SplitData = parent.split orelse return;
    const sibling_id: Id = if (split.child_ids[0] == empty_leaf.id)
        split.child_ids[1]
    else
        split.child_ids[0];
    const sibling: *DockNode = ctx.lookup(sibling_id) orelse return;

    // Adopt the sibling's role into the parent. Preserves the
    // parent's id (callers may hold it) while replacing its role.
    parent.split = sibling.split;
    if (sibling.leaf) |sl| {
        parent.leaf = .{
            .window_ids = sl.window_ids,
            .selected_window_id = sl.selected_window_id,
        };
    } else {
        parent.leaf = null;
    }
    // 9.1 fix: adopt the sibling's leaf-affinity flags (is_central, no_resize,
    // no_tab_bar, ...) while keeping the parent's structural bits (is_dockspace,
    // passthru_central). Without this, collapsing an empty sibling silently drops
    // the survivor's central/resize/tab-bar flags.
    parent.flags = sibling.flags.intersect(DockNodeFlags.transfer_mask)
        .union_(parent.flags.except(DockNodeFlags.transfer_mask));

    // Re-parent the sibling's children, if any.
    if (parent.split) |new_split| {
        for (new_split.child_ids) |cid| {
            if (ctx.lookup(cid)) |c| {
                c.parent_id = parent.id;
            }
        }
    }
    parent.generation +%= 1;

    // Free the empty leaf and the (now-orphaned) sibling shell.
    // Clear sibling's lists FIRST so we don't double-free its
    // window_ids - we already moved them into parent.leaf above.
    if (sibling.leaf) |*sl_old| {
        sl_old.window_ids = .empty;
    }
    ctx.freeNode(gpa, empty_leaf);
    ctx.freeNode(gpa, sibling);
}

/// Remove `window_id` from whatever leaf currently holds it.
/// If that leaves the leaf empty AND it has a parent SPLIT, the
/// sibling node takes the parent's slot and the parent is
/// freed. Returns true if anything changed.
pub fn undockWindow(
    ctx: *DockContext,
    gpa: Allocator,
    window_id: Id,
) bool {
    const leaf_node: *DockNode = ctx.findLeafFor(window_id) orelse return false;
    var leaf: *DockNode.LeafData = &(leaf_node.leaf orelse return false);
    const idx: usize = leaf.indexOf(window_id) orelse return false;
    _ = leaf.window_ids.orderedRemove(idx);

    // Pick a new selected tab if we just removed the active one.
    if (leaf.selected_window_id == window_id) {
        leaf.selected_window_id = if (leaf.window_ids.items.len > 0)
            leaf.window_ids.items[@min(idx, leaf.window_ids.items.len - 1)]
        else
            0;
    }
    leaf_node.generation +%= 1;

    // Collapse if the leaf is now empty and has a parent split.
    // Root leaves are kept around - a dockspace with no docked
    // windows is a valid (if visually empty) state.
    if (leaf.window_ids.items.len == 0) {
        if (leaf_node.parent_id) |pid| {
            collapseEmptyLeaf(ctx, gpa, leaf_node, pid);
        }
    }
    return true;
}

/// Dock `window_id` as a new tab into the leaf `leaf_id`. Adds
/// to the end of the tab list (rightmost tab); selects the new
/// tab automatically. No-op if `leaf_id` doesn't refer to a leaf,
/// or if the window is already in this leaf.
pub fn dockWindowAsTab(
    ctx: *DockContext,
    gpa: Allocator,
    leaf_id: Id,
    window_id: Id,
) Allocator.Error!void {
    const node: *DockNode = ctx.lookup(leaf_id) orelse return;
    var leaf: *DockNode.LeafData = &(node.leaf orelse return);
    if (leaf.indexOf(window_id) != null) {
        // Already present - promote to selected and return.
        leaf.selected_window_id = window_id;
        return;
    }
    try leaf.window_ids.append(gpa, window_id);
    leaf.selected_window_id = window_id;
    node.generation +%= 1;
}

/// walk the subtree rooted at `root_id` and clear
/// `dock_node_id` on every Window whose current dock leaf is part
/// of the subtree. Called by `dockBuilderRemoveNode` BEFORE the
/// nodes are destroyed, so we can still resolve the leaves' window
/// lists. After this returns, the dock layer can safely tear
/// down the subtree without leaving dangling `Window.dock_node_id`
/// references pointing at freed memory.
fn clearDockIdsInSubtree(ctx: *UiContext, root_id: Id) void {
    const root: *DockNode = ctx.dock.lookup(root_id) orelse return;
    if (root.leaf) |leaf| {
        // Clear dock_node_id on every window currently in this leaf.
        for (leaf.window_ids.items) |wid| {
            // We don't have a windows-by-id reverse map, so do
            // a linear walk. Tab counts per leaf are small; the
            // outer walk over all windows is bounded by the
            // total window count which is similarly small in
            // practice (~10s).
            if (ctx.windows.get(wid)) |w| {
                w.dock_node_id = null;
                // restore pre-dock pos/size. When a
                // window comes back to floating, it should land
                // back at the rect it had before it was first
                // docked - not at the leaf's last rect (which is
                // usually inside the dockspace and not where the
                // user wants it). Clears the stash so the next
                // dock cycle re-anchors fresh.
                if (w.pre_dock_pos) |pos| {
                    w.pos = pos;
                }
                if (w.pre_dock_size) |size| {
                    w.size = size;
                }
                w.pre_dock_pos = null;
                w.pre_dock_size = null;
            }
        }
    }
    if (root.split) |s| {
        clearDockIdsInSubtree(ctx, s.child_ids[0]);
        clearDockIdsInSubtree(ctx, s.child_ids[1]);
    }
}

/// Remove an entire subtree rooted at `node_id`. Frees the node
/// and all its descendants. If the node has a parent SPLIT, the
/// parent collapses the same way as an undockWindow that empties
/// a leaf. Use cases: a dockspace is torn down, or a layout
/// resets via `dockBuilderRemoveNode`.
pub fn removeNode(
    ctx: *DockContext,
    gpa: Allocator,
    node_id: Id,
) void {
    const node: *DockNode = ctx.lookup(node_id) orelse return;
    const parent_id: ?Id = node.parent_id;

    // Recurse into split children before freeing this node.
    if (node.split) |split| {
        // Snapshot child IDs because the recursive removeNode
        // calls mutate the pool.
        const children: [2]Id = split.child_ids;
        for (children) |cid| {
            removeNode(ctx, gpa, cid);
        }
    }
    ctx.freeNode(gpa, node);

    // Collapse parent if it had only this node and a sibling.
    if (parent_id) |pid| {
        if (ctx.lookup(pid)) |parent| {
            if (parent.split) |sp| {
                // If the removed node was a child of the parent,
                // re-route parent's surviving child up. We mimic
                // the collapseEmptyLeaf path but with a removed
                // child instead of an empty one.
                const surviving_id: ?Id =
                    if (sp.child_ids[0] == node_id)
                        sp.child_ids[1]
                    else if (sp.child_ids[1] == node_id)
                        sp.child_ids[0]
                    else
                        null;
                if (surviving_id) |sid| {
                    if (ctx.lookup(sid)) |survivor| {
                        parent.split = survivor.split;
                        if (survivor.leaf) |sl| {
                            parent.leaf = .{
                                .window_ids = sl.window_ids,
                                .selected_window_id = sl.selected_window_id,
                            };
                        } else {
                            parent.leaf = null;
                        }
                        // 9.1 fix: carry the survivor's leaf-affinity flags up.
                        parent.flags = survivor.flags.intersect(DockNodeFlags.transfer_mask)
                            .union_(parent.flags.except(DockNodeFlags.transfer_mask));
                        if (parent.split) |new_split| {
                            for (new_split.child_ids) |cid| {
                                if (ctx.lookup(cid)) |c| {
                                    c.parent_id = parent.id;
                                }
                            }
                        }
                        parent.generation +%= 1;
                        // Detach the survivor's list before
                        // freeing so we don't double-free.
                        if (survivor.leaf) |*sl_old| {
                            sl_old.window_ids = .empty;
                        }
                        ctx.freeNode(gpa, survivor);
                    }
                }
            }
        }
    }
}

pub const SameLineOpts = struct {
    /// Optional X position override (in window-local coordinates).
    /// 0 means use the default item spacing.
    offset_x: f32 = 0,
};

pub const ButtonOpts = struct {
    /// Override size. Zero on either axis means "auto-fit to label".
    size: Vec2 = .{ 0, 0 },
};

/// Direction the triangle glyph of an `arrowButton` points.
pub const ArrowDir = enum { left, right, up, down };

fn textImpl(
    ctx: *UiContext,
    comptime fmt: []const u8,
    args: anytype,
    color: Color,
) void {
    const w: *Window = ctx.current_window orelse return;

    // Format into a stack buffer. 256 bytes covers typical UI text;
    // longer strings get truncated cleanly. Future phases may move
    // to a per-frame arena for arbitrary lengths.
    var buf: [256]u8 = undefined;
    const formatted: []u8 = bufPrint(&buf, fmt, args) catch buf[0..0];

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const sz: Vec2 = measureTextS(ctx, formatted);
    drawTextAtS(ctx, at, formatted, Color.toWire(color));

    // Bookkeeping for IsItemHovered: even text reports a hovered rect.
    const rect = Rectangle{ .x = at[0], .y = at[1], .width = sz[0], .height = sz[1] };
    w.layout.last_item_id = 0; // text isn't interactive - no stable ID
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, rect);

    advanceLayout(w, sz, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

fn textWrappedImpl(
    ctx: *UiContext,
    comptime fmt: []const u8,
    args: anytype,
) void {
    const w: *Window = ctx.current_window orelse return;

    var buf: [512]u8 = undefined;
    const full: []u8 = bufPrint(&buf, fmt, args) catch buf[0..0];
    if (full.len == 0) {
        return;
    }

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const wrap_x: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const max_width: f32 = wrap_x - at[0];
    if (max_width <= 0) {
        // No room - just draw and advance like normal text would.
        const sz: Vec2 = measureTextS(ctx, full);
        drawTextAtS(ctx, at, full, Color.toWire(ctx.style.text));
        advanceLayout(w, sz, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return;
    }

    // Greedy word-wrap: scan tokens (whitespace-delimited), accumulate
    // until the next token would overflow `max_width`, emit a line,
    // restart. Falls back to mid-word splits for tokens longer than
    // the line.
    var line_y: f32 = at[1];
    var line_start: usize = 0;
    var i: usize = 0;
    var line_end: usize = 0; // last word boundary that fit

    while (i < full.len) : (i += 1) {
        // Find end of current word (or end of string).
        if (full[i] == ' ' or full[i] == '\n' or i + 1 == full.len) {
            const word_end = if (full[i] == ' ' or full[i] == '\n') i else i + 1;
            const candidate = full[line_start..word_end];
            const candidate_w = measureTextS(ctx, candidate)[0];
            if (candidate_w > max_width and line_end > line_start) {
                // Emit the previous-good line, restart from after it.
                const slice: []u8 = full[line_start..line_end];
                drawTextAtS(ctx, .{ at[0], line_y }, slice, Color.toWire(ctx.style.text));
                line_y += ctx.style.font_size;
                // Skip leading spaces
                line_start = line_end;
                while (line_start < full.len and full[line_start] == ' ') line_start += 1;
                line_end = line_start;
            } else {
                line_end = word_end;
            }
            // Force a break on explicit \n.
            if (i < full.len and full[i] == '\n') {
                if (line_end > line_start) {
                    const slice: []u8 = full[line_start..line_end];
                    drawTextAtS(ctx, .{ at[0], line_y }, slice, Color.toWire(ctx.style.text));
                    line_y += ctx.style.font_size;
                }
                line_start = i + 1;
                line_end = line_start;
            }
        }
    }
    // Trailing line.
    if (line_end > line_start) {
        const slice: []u8 = full[line_start..line_end];
        drawTextAtS(ctx, .{ at[0], line_y }, slice, Color.toWire(ctx.style.text));
        line_y += ctx.style.font_size;
    }

    const consumed = Vec2{ max_width, line_y - at[1] };
    advanceLayout(w, consumed, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

/// imgui-parity `LabelText`: render `label` on the LEFT and the
/// formatted value on the RIGHT of the same row. Used as a
/// readout in property panels. The value column starts at a
/// fixed offset from the row's left edge so consecutive label
/// rows visually align.
fn labelTextImpl(
    ctx: *UiContext,
    label: []const u8,
    comptime fmt: []const u8,
    args: anytype,
) void {
    const w: *Window = ctx.current_window orelse return;

    var buf: [256]u8 = undefined;
    const value_str: []u8 = bufPrint(&buf, fmt, args) catch buf[0..0];

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const value_w: f32 = measureTextS(ctx, value_str)[0];
    const label_w: f32 = measureTextS(ctx, label)[0];

    // imgui-parity: value rendered on the right of the line (or
    // at a fixed offset from row start). We follow the same
    // convention as in widget value-readouts: value first (which
    // is what the eye scans), then label. But ImGui's LabelText
    // is value-first → label-after on the same row.
    drawTextAtS(ctx, at, value_str, Color.toWire(ctx.style.text));
    if (label.len > 0) {
        const label_pos: Vec2 = .{ at[0] + value_w + ctx.style.item_spacing[0], at[1] };
        drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));
    }

    const total_w: f32 = value_w + ctx.style.item_spacing[0] + label_w;
    advanceLayout(w, .{ total_w, ctx.style.font_size }, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

fn separatorTextImpl(ctx: *UiContext, label: []const u8) void {
    const w: *Window = ctx.current_window orelse return;

    // Hardcoded constants matching imgui's `style.SeparatorText*` defaults,
    // tweaked slightly. Imgui ships padding (20, 3) + border thickness 3;
    // we use border thickness 2 - 3px reads as a bit chunky next to zimr's
    // 1px plain `separator`, while 2px stays visually distinct as a
    // "section header" without overwhelming surrounding text. Promote
    // these to `Style` fields (`separator_text_padding`, `_border_size`,
    // `_align`) if a demo wants the control.
    const padding_x: f32 = 20;
    const padding_y: f32 = 3;
    const border_thickness: f32 = 2;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const label_size: Vec2 = measureTextS(ctx, label);

    // Row height: enough for the label with padding, or at least the
    // border-only case (matches imgui's `ImMax(label.y + 2*pad.y, border)`).
    const row_height: f32 = @max(label_size[1] + padding_y * 2, border_thickness);

    // Row spans from cursor to the right edge of the window's work area
    // (window right minus right-side padding).
    const row_right_x: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const row_width: f32 = row_right_x - at[0];

    // Rules are drawn at the row's vertical centre. `rule_y` is the TOP
    // of the rule rect, so we subtract half the thickness to centre it.
    const rule_y: f32 = at[1] + row_height * 0.5 - border_thickness * 0.5;
    const rule_color: u32 = Color.toWire(ctx.style.border);

    if (label_size[0] > 0) {
        // Two-rule layout: short rule on left, label, long rule on right
        // filling to the row's right edge. imgui defaults
        // `SeparatorTextAlign.x = 0` which left-aligns the label, so the
        // left rule is just `padding_x` wide and the rest of the row goes
        // to the right rule.
        const left_rule_end_x: f32 = at[0] + padding_x;
        const label_x: f32 = left_rule_end_x + ctx.style.item_spacing[0];
        const label_y: f32 = at[1] + (row_height - label_size[1]) * 0.5;
        const right_rule_start_x: f32 = label_x + label_size[0] + ctx.style.item_spacing[0];

        // Left rule.
        drawRectFilled(ctx, .{
            .x = at[0],
            .y = rule_y,
            .width = left_rule_end_x - at[0],
            .height = border_thickness,
        }, rule_color);

        // Label.
        drawTextAtS(ctx, .{ label_x, label_y }, label, Color.toWire(ctx.style.text));

        // Right rule (if there's room for it after the label).
        const right_rule_width: f32 = row_right_x - right_rule_start_x;
        if (right_rule_width > 0) {
            drawRectFilled(ctx, .{
                .x = right_rule_start_x,
                .y = rule_y,
                .width = right_rule_width,
                .height = border_thickness,
            }, rule_color);
        }
        // NOTE: imgui's SeparatorTextEx sets `CursorPosPrevLine.x` to
        // `label_pos.x + label_size.x` so a subsequent `SameLine()`
        // places the next widget right after the label (between left
        // and right rules). zimr's layout state machine drives sameLine
        // off `cursor_max[0]`, which the `advanceLayout` below will set
        // to the row's right edge. Replicating the "sameLine after
        // separatorText lands next to the label" trick would require
        // splitting the advance into two parts (advance Y past the row,
        // but report consumed X as label_end). Deferred - no current
        // demo needs it.
    } else {
        // Empty label - degrade to a full-width rule at the row's centre.
        drawRectFilled(ctx, .{
            .x = at[0],
            .y = rule_y,
            .width = row_width,
            .height = border_thickness,
        }, rule_color);
    }

    // Bookkeeping. separatorText is not interactive, so no widget ID
    // and `last_item_hovered` stays false.
    w.layout.last_item_id = 0;
    w.layout.last_item_rect = .{ .x = at[0], .y = at[1], .width = row_width, .height = row_height };
    w.layout.last_item_hovered = false;

    advanceLayout(w, .{ row_width, row_height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

/// Returns true when the given focusable widget should treat this
/// frame as a synthesized click - i.e. it's the focused widget AND
/// Enter was pressed. Used by button/checkbox to wire keyboard
/// activation alongside their existing mouse-click handling.
fn triggerNavActivate(ctx: *const UiContext, id: Id) bool {
    const is_focused: bool = ctx.nav_id == id;
    if (!is_focused) {
        return false;
    }
    return ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame;
}

/// Per-widget post-bookkeeping: computes the activation-transition
/// flags (`isItemActivated`, `isItemDeactivated`,
/// `isItemDeactivatedAfterEdit`) and the click flag (`isItemClicked`)
/// from state already populated by the widget (`w.layout.last_item_id`,
/// `w.layout.last_item_hovered`).
/// Each widget calls this exactly once at the end of its submission,
/// passing whether the user mutated the value this frame
/// (`edited = true`). Most widgets pass `false` because they don't
/// mutate; the seven that do (checkbox, slider, drag, colorEdit,
/// combo, radioButton, inputText) pass `true` only on the frame the
/// mutation actually happened.
/// Centralising the transition logic here means each widget needs one
/// line of new code (`markItemPost(ctx, w, edited);`) rather than
/// re-implementing the active_id-vs-prev_active_id comparison inline
/// in 8 places.
fn markItemPost(
    ctx: *UiContext,
    w: *Window,
    edited: bool,
) void {
    const id: Id = w.layout.last_item_id;
    // Non-interactive items (text, image, bullet) record id=0 and
    // skip the activation-transition computation - their "active",
    // "clicked", "edited" queries should always return false.
    if (id == 0) {
        w.layout.last_item_edited = false;
        w.layout.last_item_clicked = false;
        w.layout.last_item_activated = false;
        w.layout.last_item_deactivated = false;
        w.layout.last_item_deactivated_after_edit = false;
        return;
    }
    w.layout.last_item_edited = edited;

    // Click detection: hovered AND mouse-down went from up → down
    // this frame. ImGui uses a richer timing model; this matches
    // `IsItemClicked` semantics in 95% of usage.
    w.layout.last_item_clicked = w.layout.last_item_hovered and ctx.input.mouse_left_clicked;

    // Activation transitions - derive from active_id vs.
    // active_id_prev_frame. An ID transitioning into active_id this
    // frame is "activated"; the reverse is "deactivated".
    const was_active: bool = (ctx.active_id_prev_frame == id);
    const is_active: bool = (ctx.active_id == id);
    w.layout.last_item_activated = is_active and !was_active;
    w.layout.last_item_deactivated = was_active and !is_active;

    // Sticky "was-edited-during-activation" flag on the context.
    // While the widget is active, any frame that reports `edited`
    // sets the flag; on deactivation we surface it through
    // `last_item_deactivated_after_edit`, then clear the sticky
    // value for the next activation.
    if (is_active and edited) {
        ctx.active_id_was_edited = true;
    }
    if (w.layout.last_item_deactivated) {
        w.layout.last_item_deactivated_after_edit = ctx.active_id_was_edited;
        ctx.active_id_was_edited = false;
    } else {
        w.layout.last_item_deactivated_after_edit = false;
    }
}

fn invisibleButtonImpl(
    ctx: *UiContext,
    id_str: []const u8,
    size_arg: Vec2,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, id_str);

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // 0-on-axis means "use a sensible default." X: fill remaining row
    // width to the right-side window padding. Y: standard widget row
    // height (`font_size + 2 * frame_padding[1]`, matching imgui's
    // `GetFrameHeight()`). Mirrors imgui's `0 → -FLT_MIN → fill` for
    // the X axis.
    const row_right_x: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const available_x: f32 = row_right_x - at[0];
    const default_h: f32 = ctx.style.font_size + 2 * ctx.style.frame_padding[1];
    const size: Vec2 = .{
        if (size_arg[0] > 0) size_arg[0] else @max(available_x, 1),
        if (size_arg[1] > 0) size_arg[1] else default_h,
    };

    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = size[0], .height = size[1] };

    // Hit test + state machine. Same shape as buttonImpl - only the
    // draw step is omitted (that's the whole point of "invisible").
    const hovered: bool = pointInRect(ctx.input.mouse_pos, rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    var pressed: bool = false;
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    if (ctx.active_id == id and ctx.input.mouse_left_released) {
        if (hovered) {
            pressed = true;
        }
        ctx.active_id = 0;
    }
    if (triggerNavActivate(ctx, id)) {
        pressed = true;
    }

    // NO draw. That's what makes it invisible.

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);
    // Skip `markItemNavigable` - imgui defaults `InvisibleButton` to
    // `NoNav` (Tab skips it) because invisible regions usually exist as
    // hit zones for other things and shouldn't gain focus indicators.
    // Add an `enable_nav: bool = false` opt later if a use case wants
    // tab focus on an invisible region.

    advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return pressed;
}

pub const SliderOpts = struct {
    /// Lower bound (inclusive). Stored as f64 to be lossless across
    /// every numeric `value` pointer type the widget accepts.
    min: f64 = 0,
    /// Upper bound (inclusive).
    max: f64 = 1,
    /// Format string for the value display. `std.fmt`-compatible.
    /// For ints, `{d}` is appropriate; for floats, `{d:.3}`.
    fmt: []const u8 = "{d:.3}",
    /// Override the inline width (full row by default = window
    /// content width minus value-text width).
    width: f32 = 0,
};

/// Format a scalar (any int or float) into a fixed buffer using a
/// type-appropriate default format. `std.fmt.bufPrint` requires
/// comptime format strings, which means we can't honor the user's
/// `opts.fmt` field directly without making opts itself a comptime
/// argument - too invasive for. The defaults below match
/// what most users want:
///   - floats: 3 decimal places (`"{d:.3}"`)
///   - integers: no decimals (`"{d}"`)
/// may revisit with a comptime-format generic or an enum-of-
/// styles trick to make `opts.fmt` actually wire in.
fn formatScalar(
    buf: []u8,
    comptime T: type,
    v: T,
    _fmt_runtime: []const u8,
) []const u8 {
    _ = _fmt_runtime; // reserved for (see comment above)
    return switch (@typeInfo(T)) {
        .float => bufPrint(buf, "{d:.3}", .{v}) catch buf[0..0],
        .int => bufPrint(buf, "{d}", .{v}) catch buf[0..0],
        else => buf[0..0],
    };
}

/// Convert f64 back to any int/float (used to commit the new slider
/// value). Truncates for ints, casts for floats.
fn scalarFromF64(comptime T: type, v: f64) T {
    return switch (@typeInfo(T)) {
        .float => @floatCast(v),
        .int => @round(v),
        else => @compileError("scalarFromF64: unsupported"),
    };
}

/// Convert any int/float to f64 (used to position the slider grab).
fn scalarToF64(comptime T: type, v: T) f64 {
    return switch (@typeInfo(T)) {
        .float => @floatCast(v),
        .int => @floatFromInt(v),
        else => @compileError("scalarToF64: unsupported"),
    };
}

fn sliderScalar(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    comptime T: type,
    opts: SliderOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    // Layout: the bar fills available row width minus space for the
    // value text on the right. Label appears AFTER the bar (matches
    // ImGui), separated by item_spacing.
    const padding: Vec2 = ctx.style.frame_padding;
    const bar_h: f32 = ctx.style.font_size + 2 * padding[1];
    const label_size: Vec2 = measureTextS(ctx, label);

    // Compute current value text for sizing.
    var val_buf: [64]u8 = undefined;
    const val_str = formatScalar(&val_buf, T, value.*, opts.fmt);
    const val_size: Vec2 = measureTextS(ctx, val_str);

    const inner_w: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0] - (resolveCursor(
        w,
        ctx.style.item_spacing[0],
    )[0]);
    const value_text_reserve: f32 = val_size[0] + ctx.style.item_spacing[0];
    const label_reserve: f32 = label_size[0] + ctx.style.item_spacing[0];
    const default_bar_w: f32 = inner_w - label_reserve;
    var bar_w: f32 = effectiveItemWidth(ctx, opts.width, default_bar_w);
    if (bar_w < 60) {
        bar_w = 60; // sensible floor
    }

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const bar = Rectangle{ .x = at[0], .y = at[1], .width = bar_w, .height = bar_h };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, bar);
    if (hovered) {
        ctx.hovered_id = id;
    }

    // State machine: clicking + holding within the bar puts us in
    // "active" mode; we update the value from mouse X every frame
    // until release. Clamp value to [min, max].
    var changed: bool = false;
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        const t: f32 = clamp((ctx.input.mouse_pos[0] - bar.x) / bar.width, 0.0, 1.0);
        const new_f: f64 = opts.min + @as(f64, t) * (opts.max - opts.min);
        const new_v: T = scalarFromF64(T, new_f);
        if (new_v != value.*) {
            value.* = new_v;
            changed = true;
            // Re-render value text since it changed.
            _ = formatScalar(&val_buf, T, value.*, opts.fmt);
        }
    }

    // Draw bar background.
    const bar_bg: Color = if (ctx.active_id == id)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, bar, Color.toWire(bar_bg));

    // Draw the grab - a thin vertical bar at the value's position.
    const range: f64 = opts.max - opts.min;
    const tnorm: f32 = if (range > 0)
        @floatCast(clamp((@as(f64, scalarToF64(T, value.*)) - opts.min) / range, 0.0, 1.0))
    else
        0;
    const grab_w: f32 = 8;
    const grab_x: f32 = bar.x + tnorm * (bar.width - grab_w);
    const grab_color: Color = if (ctx.active_id == id)
        ctx.style.button_active
    else
        ctx.style.button;
    drawRectFilled(
        ctx,
        .{ .x = grab_x, .y = bar.y + 2, .width = grab_w, .height = bar.height - 4 },
        Color.toWire(grab_color),
    );

    // Draw value text centered in the bar.
    const val_pos = Vec2{ bar.x + (bar.width - val_size[0]) / 2, bar.y + (bar.height - ctx.style.font_size) / 2 };
    drawTextAtS(ctx, val_pos, val_str, Color.toWire(ctx.style.text));

    // Draw the label to the right of the bar.
    const label_pos = Vec2{
        bar.x + bar.width + ctx.style.item_spacing[0],
        bar.y + (bar.height - ctx.style.font_size) / 2,
    };
    drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));

    // Bookkeeping.
    const total_w: f32 = bar.width + ctx.style.item_spacing[0] + label_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = bar.x, .y = bar.y, .width = total_w, .height = bar.height };
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, changed);

    advanceLayout(w, .{ total_w, bar.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    _ = value_text_reserve; // future: position value text outside bar
    return changed;
}

fn sliderDispatch(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    opts: SliderOpts,
) bool {
    // lint #1: slider's range must be valid — min < max. Equal
    // bounds produce a degenerate bar with no usable thumb travel and
    // hidden divide-by-zero behavior inside normaliseValue. Warns
    // once per call site for the process lifetime.
    if (opts.min >= opts.max) {
        warnOnce(
            @src(),
            "ui.slider({s}): degenerate range — min={d} >= max={d}",
            .{ label, opts.min, opts.max },
        );
    }
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.slider: pass a single-item pointer (e.g., &my_value), got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    return switch (@typeInfo(Child)) {
        .float => sliderScalar(ctx, label, value, Child, opts),
        .int => sliderScalar(ctx, label, value, Child, opts),
        .array => |arr| blk: {
            // Multi-component slider: `*[N]T` renders N narrow bars
            // side-by-side, each with its own click+drag state, sharing
            // the row width. Element type still has to be float or int.
            switch (@typeInfo(arr.child)) {
                .float, .int => {},
                else => @compileError("ui.slider: array element must be float or int, got " ++ @typeName(arr.child)),
            }
            break :blk sliderArray(ctx, label, value, arr.child, arr.len, opts);
        },
        else => @compileError("ui.slider: child type must be float, int, or [N]float/int, got " ++ @typeName(Child)),
    };
}

fn colorEditColor(
    ctx: *UiContext,
    label: []const u8,
    value: *Color,
    opts: ColorEditOpts,
) bool {
    // Convert to [4]f32, edit, convert back. Lossy at ±1/255 per
    // channel, fine for a color picker.
    var f4: [4]f32 = .{
        float(value.r) / 255.0,
        float(value.g) / 255.0,
        float(value.b) / 255.0,
        float(value.a) / 255.0,
    };
    const changed: bool = colorEditNFloat(ctx, label, &f4, 4, opts);
    if (changed) {
        value.* = .{
            .r = @trunc(clamp(f4[0], 0.0, 1.0) * 255),
            .g = @trunc(clamp(f4[1], 0.0, 1.0) * 255),
            .b = @trunc(clamp(f4[2], 0.0, 1.0) * 255),
            .a = @trunc(clamp(f4[3], 0.0, 1.0) * 255),
        };
    }
    return changed;
}

fn colorEditDispatch(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    opts: ColorEditOpts,
) bool {
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.colorEdit: pass a single-item pointer, got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    // The Float3/Float4 cases are just colorEditNFloat with the
    // channel count bound at the call site - no need for separate
    // shim functions. The Color case has its own implementation
    // (handles u8 channels + alpha differently).
    if (Child == [3]f32) {
        return colorEditNFloat(ctx, label, value, 3, opts);
    }
    if (Child == [4]f32) {
        return colorEditNFloat(ctx, label, value, 4, opts);
    }
    if (Child == Color) {
        return colorEditColor(ctx, label, value, opts);
    }
    @compileError("ui.colorEdit: pointed-to type must be [3]f32, [4]f32, or zimr Color, got " ++ @typeName(Child));
}

pub const PlotOpts = struct {
    /// Optional overlay text drawn centered near the top of the
    /// plot frame. Typical use: current value or a stat summary.
    overlay: []const u8 = "",
    /// Value-axis lower bound. When `min == max`, the plot
    /// auto-fits to the observed data range each frame.
    min: f32 = 0,
    /// Value-axis upper bound. See `min`.
    max: f32 = 0,
    /// Plot frame width. Zero = fill remaining row width.
    width: f32 = 0,
    /// Plot frame height in pixels. Zero = default of 80.
    height: f32 = 0,
};

/// Pack four normalized floats into a ColorU32 in zimr's RGBA8
/// wire format.
fn packF(r: f32, g: f32, b: f32, a: f32) ColorU32 {
    return Color.fromFloats(r, g, b, a).toWire();
}

/// Horizontal hue gradient. `segments` controls smoothness.
fn drawHueStrip(
    dl: *DrawList,
    alloc: Allocator,
    rect: Rectangle,
    segments: u32,
) void {
    const seg_w: f32 = rect.width / float(segments);
    var i: u32 = 0;
    while (i < segments) : (i += 1) {
        const h: f32 = float(i) / float(segments);
        const rgb: [3]f32 = hsvToRgb3(h, 1, 1);
        const col: ColorU32 = packF(rgb[0], rgb[1], rgb[2], 1);
        dl.addRectFilled(alloc, .{
            .x = rect.x + float(i) * seg_w,
            .y = rect.y,
            .width = seg_w + 1,
            .height = rect.height,
        }, col);
    }
}

/// SV box at fixed hue. Drawn as a vertical stack of slim slabs.
/// Each slab samples value at its y position and renders a 2-color
/// (S=0 dark, S=1 fully saturated) horizontal gradient.
fn drawSvBox(
    dl: *DrawList,
    alloc: Allocator,
    rect: Rectangle,
    h: f32,
    slabs: u32,
) void {
    const slab_h: f32 = rect.height / float(slabs);
    var i: u32 = 0;
    while (i < slabs) : (i += 1) {
        const v: f32 = 1.0 - (float(i) / float(slabs - 1));
        const rgb_left: [3]f32 = hsvToRgb3(h, 0, v);
        const rgb_right: [3]f32 = hsvToRgb3(h, 1, v);
        const c_l: ColorU32 = packF(rgb_left[0], rgb_left[1], rgb_left[2], 1);
        const c_r: ColorU32 = packF(rgb_right[0], rgb_right[1], rgb_right[2], 1);
        dl.addRectFilledMultiColor(alloc, .{
            .x = rect.x,
            .y = rect.y + float(i) * slab_h,
            .width = rect.width,
            .height = slab_h + 1,
        }, c_l, c_r, c_r, c_l);
    }
}

fn handleHueBar(
    ctx: *UiContext,
    rect: Rectangle,
    id: Id,
    h_ptr: *f32,
    changed: *bool,
) void {
    if (pointInRect(ctx.input.mouse_pos, rect) and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        const h_new: f32 = clamp((ctx.input.mouse_pos[0] - rect.x) / rect.width, 0, 1);
        if (h_new != h_ptr.*) {
            h_ptr.* = h_new;
            changed.* = true;
        }
    }
}

fn handleSvBox(
    ctx: *UiContext,
    rect: Rectangle,
    id: Id,
    s_ptr: *f32,
    v_ptr: *f32,
    changed: *bool,
) void {
    if (pointInRect(ctx.input.mouse_pos, rect) and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        const s_new: f32 = clamp((ctx.input.mouse_pos[0] - rect.x) / rect.width, 0, 1);
        const v_new: f32 = clamp(1.0 - (ctx.input.mouse_pos[1] - rect.y) / rect.height, 0, 1);
        if (s_new != s_ptr.* or v_new != v_ptr.*) {
            s_ptr.* = s_new;
            v_ptr.* = v_new;
            changed.* = true;
        }
    }
}

/// Alpha bar - current color from full-alpha (right) to zero-alpha
/// (left), letting users see the result of alpha blending against
/// the window background.
fn drawAlphaBar(
    dl: *DrawList,
    alloc: Allocator,
    rect: Rectangle,
    r: f32,
    g: f32,
    b: f32,
) void {
    const c0: ColorU32 = packF(r, g, b, 0);
    const c1: ColorU32 = packF(r, g, b, 1);
    dl.addRectFilledMultiColor(alloc, rect, c0, c1, c1, c0);
}

fn handleAlphaBar(
    ctx: *UiContext,
    rect: Rectangle,
    id: Id,
    a_ptr: *f32,
    changed: *bool,
) void {
    if (pointInRect(ctx.input.mouse_pos, rect) and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        const a_new: f32 = clamp((ctx.input.mouse_pos[0] - rect.x) / rect.width, 0, 1);
        if (a_new != a_ptr.*) {
            a_ptr.* = a_new;
            changed.* = true;
        }
    }
}

/// Hue ring drawn as N wedges (filled triangles). `inner_r` is
/// the SV-box bound; the ring fills `[inner_r, outer_r]`.
fn drawHueRing(
    dl: *DrawList,
    alloc: Allocator,
    center: Vec2,
    outer_r: f32,
    inner_r: f32,
    segments: u32,
) void {
    const da: f32 = tau / float(segments);
    var i: u32 = 0;
    while (i < segments) : (i += 1) {
        const a0: f32 = float(i) * da;
        const a1: f32 = a0 + da;
        const h_mid: f32 = (float(i) + 0.5) / float(segments);
        const rgb: [3]f32 = hsvToRgb3(h_mid, 1, 1);
        const col: ColorU32 = packF(rgb[0], rgb[1], rgb[2], 1);
        // Two triangles per wedge: outer-a0 / outer-a1 / inner-a0 +
        // outer-a1 / inner-a1 / inner-a0.
        const o0: Vec2 = .{ center[0] + @cos(a0) * outer_r, center[1] + @sin(a0) * outer_r };
        const o1: Vec2 = .{ center[0] + @cos(a1) * outer_r, center[1] + @sin(a1) * outer_r };
        const in_a: Vec2 = .{ center[0] + @cos(a0) * inner_r, center[1] + @sin(a0) * inner_r };
        const in_b: Vec2 = .{ center[0] + @cos(a1) * inner_r, center[1] + @sin(a1) * inner_r };
        dl.addTriangleFilled(alloc, o0, o1, in_a, col);
        dl.addTriangleFilled(alloc, o1, in_b, in_a, col);
    }
}

fn handleHueRing(
    ctx: *UiContext,
    center: Vec2,
    outer_r: f32,
    inner_r: f32,
    id: Id,
    h_ptr: *f32,
    changed: *bool,
) void {
    const dx: f32 = ctx.input.mouse_pos[0] - center[0];
    const dy: f32 = ctx.input.mouse_pos[1] - center[1];
    const d2: f32 = dx * dx + dy * dy;
    const in_ring: bool = d2 >= inner_r * inner_r and d2 <= outer_r * outer_r;
    if (in_ring and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        var ang: f32 = atan2(dy, dx);
        if (ang < 0) {
            ang += tau;
        }
        const h_new: f32 = ang / tau;
        if (h_new != h_ptr.*) {
            h_ptr.* = h_new;
            changed.* = true;
        }
    }
}

fn drawAlphaBarVertical(
    dl: *DrawList,
    alloc: Allocator,
    rect: Rectangle,
    r: f32,
    g: f32,
    b: f32,
) void {
    const c0: ColorU32 = packF(r, g, b, 0); // bottom = alpha 0
    const c1: ColorU32 = packF(r, g, b, 1); // top = alpha 1
    dl.addRectFilledMultiColor(alloc, rect, c1, c1, c0, c0);
}

fn handleAlphaBarVertical(
    ctx: *UiContext,
    rect: Rectangle,
    id: Id,
    a_ptr: *f32,
    changed: *bool,
) void {
    if (pointInRect(ctx.input.mouse_pos, rect) and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        const a_new: f32 = clamp(1.0 - (ctx.input.mouse_pos[1] - rect.y) / rect.height, 0, 1);
        if (a_new != a_ptr.*) {
            a_ptr.* = a_new;
            changed.* = true;
        }
    }
}

/// Core picker impl. All inputs are f32 in [0, 1]. Pass `a_ptr =
/// null` to disable the alpha bar. The bar layout draws a hue
/// strip + an SV box (sat horizontal, value vertical). The wheel
/// layout draws a hue ring with an SV square inscribed.
/// Convention: SV box has S=0 at left, S=1 at right, V=1 at top,
/// V=0 at bottom - matches imgui.
fn colorPickerImpl(
    ctx: *UiContext,
    label: []const u8,
    r_ptr: *f32,
    g_ptr: *f32,
    b_ptr: *f32,
    a_ptr: ?*f32,
    opts: ColorPickerOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const dl: *DrawList = &w.draw_list;
    const arena: Allocator = ctx.frame_arena.allocator();
    const parent_id: Id = widgetId(ctx, w, label);

    const size: f32 = if (opts.size > 0) opts.size else 180;
    const start: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // Always work in HSV space inside the picker.
    var hsv: [3]f32 = rgbToHsv3(r_ptr.*, g_ptr.*, b_ptr.*);
    var alpha_val: f32 = if (a_ptr) |ap| ap.* else 1.0;

    var any_changed: bool = false;

    // ---- Layout
    // Both layouts share: SV box on the right, swatch + label below.
    // .bar adds a hue strip ABOVE the SV box.
    // .wheel adds a hue ring AROUND the SV box (slightly larger
    //   footprint for the ring radius).

    switch (opts.layout) {
        .bar => {
            // Hue strip: thin horizontal gradient across `size` width
            // above the SV box.
            const hue_h: f32 = 16;
            const hue_rect: Rectangle = .{ .x = start[0], .y = start[1], .width = size, .height = hue_h };
            drawHueStrip(dl, arena, hue_rect, 64);

            const sv_rect: Rectangle = .{ .x = start[0], .y = start[1] + hue_h + 4, .width = size, .height = size };
            // SV gradient drawn as a vertical stack of slim
            // horizontal slabs. Each slab is one V level; within a
            // slab we use a 2-color gradient from (h,0,V)→(h,1,V).
            drawSvBox(dl, arena, sv_rect, hsv[0], 24);

            // Interaction: hue strip drag, then SV box drag.
            const hue_id: Id = hashInt(parent_id, 0);
            const sv_id: Id = hashInt(parent_id, 1);
            handleHueBar(ctx, hue_rect, hue_id, &hsv[0], &any_changed);
            handleSvBox(ctx, sv_rect, sv_id, &hsv[1], &hsv[2], &any_changed);

            // Indicator: a small ring at the active hue / SV point.
            const hx: f32 = hue_rect.x + hsv[0] * hue_rect.width;
            dl.addLine(arena, .{ hx, hue_rect.y - 1 }, .{ hx, hue_rect.y + hue_rect.height + 1 }, 0xFF000000, 2);
            const sx: f32 = sv_rect.x + hsv[1] * sv_rect.width;
            const sy: f32 = sv_rect.y + (1.0 - hsv[2]) * sv_rect.height;
            dl.addCircle(arena, .{ sx, sy }, 6, 0xFFFFFFFF, 2, 16);

            // Optional alpha bar BELOW the SV box.
            if (a_ptr != null) {
                const alpha_rect: Rectangle = .{
                    .x = start[0],
                    .y = sv_rect.y + sv_rect.height + 4,
                    .width = size,
                    .height = hue_h,
                };
                drawAlphaBar(dl, arena, alpha_rect, r_ptr.*, g_ptr.*, b_ptr.*);
                const alpha_id: Id = hashInt(parent_id, 2);
                handleAlphaBar(ctx, alpha_rect, alpha_id, &alpha_val, &any_changed);
                const ax: f32 = alpha_rect.x + alpha_val * alpha_rect.width;
                dl.addLine(
                    arena,
                    .{ ax, alpha_rect.y - 1 },
                    .{ ax, alpha_rect.y + alpha_rect.height + 1 },
                    0xFF000000,
                    2,
                );
            }
        },
        .wheel => {
            // Wheel: hue ring around the picker, SV box inscribed.
            const radius: f32 = size * 0.5;
            const center: Vec2 = .{ start[0] + radius, start[1] + radius };
            const inner_r: f32 = radius - 18; // ring thickness 18
            drawHueRing(dl, arena, center, radius, inner_r, 96);

            // Inscribed SV box - a square inside the inner radius.
            const box_size: f32 = inner_r * sqrt1_2 * 1.6;
            const sv_rect: Rectangle = .{
                .x = center[0] - box_size * 0.5,
                .y = center[1] - box_size * 0.5,
                .width = box_size,
                .height = box_size,
            };
            drawSvBox(dl, arena, sv_rect, hsv[0], 24);

            const hue_id: Id = hashInt(parent_id, 0);
            const sv_id: Id = hashInt(parent_id, 1);
            handleHueRing(ctx, center, radius, inner_r, hue_id, &hsv[0], &any_changed);
            handleSvBox(ctx, sv_rect, sv_id, &hsv[1], &hsv[2], &any_changed);

            // Ring indicator + SV cursor.
            const ang: f32 = hsv[0] * tau;
            const r_mid: f32 = (radius + inner_r) * 0.5;
            const ind_x: f32 = center[0] + @cos(ang) * r_mid;
            const ind_y: f32 = center[1] + @sin(ang) * r_mid;
            dl.addCircle(arena, .{ ind_x, ind_y }, 5, 0xFFFFFFFF, 2, 16);
            const sx: f32 = sv_rect.x + hsv[1] * sv_rect.width;
            const sy: f32 = sv_rect.y + (1.0 - hsv[2]) * sv_rect.height;
            dl.addCircle(arena, .{ sx, sy }, 6, 0xFFFFFFFF, 2, 16);

            if (a_ptr != null) {
                // Alpha bar to the right of the wheel.
                const alpha_rect: Rectangle = .{ .x = start[0] + size + 4, .y = start[1], .width = 16, .height = size };
                drawAlphaBarVertical(dl, arena, alpha_rect, r_ptr.*, g_ptr.*, b_ptr.*);
                const alpha_id: Id = hashInt(parent_id, 2);
                handleAlphaBarVertical(ctx, alpha_rect, alpha_id, &alpha_val, &any_changed);
                const ay: f32 = alpha_rect.y + (1.0 - alpha_val) * alpha_rect.height;
                dl.addLine(
                    arena,
                    .{ alpha_rect.x - 1, ay },
                    .{ alpha_rect.x + alpha_rect.width + 1, ay },
                    0xFF000000,
                    2,
                );
            }
        },
    }

    // Convert HSV back to RGB if anything changed.
    if (any_changed) {
        const rgb: [3]f32 = hsvToRgb3(hsv[0], hsv[1], hsv[2]);
        r_ptr.* = rgb[0];
        g_ptr.* = rgb[1];
        b_ptr.* = rgb[2];
        if (a_ptr) |ap| {
            ap.* = alpha_val;
        }
    }

    // Final swatch + label below the picker.
    const total_h: f32 = switch (opts.layout) {
        .bar => size + 16 + 4 + (if (a_ptr != null) @as(f32, 16 + 4) else 0),
        .wheel => size,
    };
    const swatch_y: f32 = start[1] + total_h + 6;
    const swatch_rect: Rectangle = .{ .x = start[0], .y = swatch_y, .width = 40, .height = 20 };
    const final_col: Color = .{
        .r = @trunc(clamp(r_ptr.* * 255.0, 0, 255)),
        .g = @trunc(clamp(g_ptr.* * 255.0, 0, 255)),
        .b = @trunc(clamp(b_ptr.* * 255.0, 0, 255)),
        .a = @trunc(clamp(alpha_val * 255.0, 0, 255)),
    };
    dl.addRectFilled(arena, swatch_rect, Color.toWire(final_col));

    if (label.len > 0) {
        const label_pos: Vec2 = .{
            swatch_rect.x + swatch_rect.width + ctx.style.item_spacing[0],
            swatch_rect.y + (swatch_rect.height - ctx.style.font_size) * 0.5,
        };
        drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));
    }

    const total_consumed_h: f32 = total_h + 6 + 20;
    const total_consumed_w: f32 = switch (opts.layout) {
        .bar => size,
        .wheel => size + (if (a_ptr != null) @as(f32, 4 + 16) else 0),
    };
    w.layout.last_item_id = parent_id;
    w.layout.last_item_rect = .{ .x = start[0], .y = start[1], .width = total_consumed_w, .height = total_consumed_h };
    markItemPost(ctx, w, any_changed);

    advanceLayout(
        w,
        .{ total_consumed_w, total_consumed_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return any_changed;
}

/// Color picker over zimr `Color` (u8 RGBA). Converts to f32,
/// runs the picker, writes back on change.
fn colorPickerColor(
    ctx: *UiContext,
    label: []const u8,
    value: *Color,
    opts: ColorPickerOpts,
) bool {
    var r: f32 = float(value.r) / 255.0;
    var g: f32 = float(value.g) / 255.0;
    var b: f32 = float(value.b) / 255.0;
    var a: f32 = float(value.a) / 255.0;
    const changed: bool = colorPickerImpl(ctx, label, &r, &g, &b, if (opts.alpha) &a else null, opts);
    if (changed) {
        value.r = @trunc(clamp(r * 255.0, 0, 255));
        value.g = @trunc(clamp(g * 255.0, 0, 255));
        value.b = @trunc(clamp(b * 255.0, 0, 255));
        value.a = @trunc(clamp(a * 255.0, 0, 255));
    }
    return changed;
}

fn colorPickerDispatch(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    opts: ColorPickerOpts,
) bool {
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.colorPicker: pass a single-item pointer, got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    return switch (@typeInfo(Child)) {
        .array => |arr| blk: {
            if (arr.child != f32) {
                @compileError("ui.colorPicker: array element must be f32, got " ++ @typeName(arr.child));
            }
            if (arr.len == 3) {
                break :blk colorPickerImpl(ctx, label, &value[0], &value[1], &value[2], null, opts);
            }
            if (arr.len == 4) {
                break :blk colorPickerImpl(ctx, label, &value[0], &value[1], &value[2], &value[3], opts);
            }
            @compileError("ui.colorPicker: array length must be 3 or 4, got " ++ @as(
                []const u8,
                std.fmt.comptimePrint("{d}", .{arr.len}),
            ));
        },
        .@"struct" => blk: {
            // Treat as zimr Color (u8 RGBA). Convert u8 → f32 in,
            // back out on change.
            if (Child != Color) {
                @compileError("ui.colorPicker: pointed-to struct must be zimr Color, got " ++ @typeName(Child));
            }
            break :blk colorPickerColor(ctx, label, value, opts);
        },
        else => @compileError(
            "ui.colorPicker: pointed-to type must be [3]f32, [4]f32, or zimr Color, got " ++ @typeName(Child),
        ),
    };
}

pub const DragOpts = struct {
    /// Pixels of mouse drag per unit value change. `0.01` ⇒ very
    /// fine; `1.0` ⇒ coarse. Sign of speed never inverted.
    speed: f32 = 1.0,
    /// Lower clamp (inclusive). Stored f64 to be lossless across
    /// every numeric pointer type the widget accepts. Set
    /// `min == max` to disable clamping.
    min: f64 = 0,
    /// Upper clamp (inclusive). See `min`.
    max: f64 = 0,
    /// Inline width override (default: 80 - narrower than slider).
    width: f32 = 80,
    /// Reserved - see comment in `formatScalar`. Currently ignored.
    fmt: []const u8 = "{d:.3}",
};

fn dragScalar(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    comptime T: type,
    opts: DragOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const bar_h: f32 = ctx.style.font_size + 2 * padding[1];
    const bar_w: f32 = effectiveItemWidth(ctx, opts.width, 80);
    const label_size: Vec2 = measureTextS(ctx, label);

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const bar = Rectangle{ .x = at[0], .y = at[1], .width = bar_w, .height = bar_h };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, bar);
    if (hovered) {
        ctx.hovered_id = id;
    }

    var changed: bool = false;
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    if (ctx.active_id == id and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }
    if (ctx.active_id == id) {
        // Pixel-delta drag: each frame's horizontal mouse motion moves
        // the value by speed * delta. We re-derive delta by
        // remembering the previous mouse_pos in the active_id state.
        // For, simple-and-correct: derive delta from
        // (current - press position) and scale. More finicky than
        // ImGui's per-frame accumulation but easy to reason about.
        const cur_f: f64 = scalarToF64(T, value.*);
        const dx: f32 = ctx.input.mouse_pos[0] - ctx.active_id_press_x;
        const new_f: f64 = ctx.active_id_press_value + @as(f64, dx) * @as(f64, opts.speed);
        const clamped: f64 = if (opts.min == opts.max)
            new_f
        else
            clamp(new_f, opts.min, opts.max);
        if (clamped != cur_f) {
            value.* = scalarFromF64(T, clamped);
            changed = true;
        }
    }
    if (ctx.active_id == id and ctx.active_id_just_activated) {
        // First-frame-of-active: snapshot the press position + initial
        // value so subsequent frames can compute correct deltas.
        ctx.active_id_press_x = ctx.input.mouse_pos[0];
        ctx.active_id_press_value = scalarToF64(T, value.*);
    }

    // Draw bar background.
    const bg: Color = if (ctx.active_id == id)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, bar, Color.toWire(bg));

    // Draw value text centered.
    var val_buf: [64]u8 = undefined;
    const val_str = formatScalar(&val_buf, T, value.*, opts.fmt);
    const val_size: Vec2 = measureTextS(ctx, val_str);
    drawTextAtS(
        ctx,
        .{ bar.x + (bar.width - val_size[0]) / 2, bar.y + (bar.height - ctx.style.font_size) / 2 },
        val_str,
        Color.toWire(ctx.style.text),
    );

    // Draw label to the right.
    drawTextAtS(
        ctx,
        .{ bar.x + bar.width + ctx.style.item_spacing[0], bar.y + (bar.height - ctx.style.font_size) / 2 },
        label,
        Color.toWire(ctx.style.text),
    );

    const total_w: f32 = bar.width + ctx.style.item_spacing[0] + label_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = bar.x, .y = bar.y, .width = total_w, .height = bar.height };
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, changed);

    advanceLayout(w, .{ total_w, bar.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return changed;
}

fn dragDispatch(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    opts: DragOpts,
) bool {
    // lint #2: drag range — `min == max` is the documented
    // "disable clamping" sentinel (see DragOpts.min docstring), so
    // we only warn on STRICTLY inverted bounds (min > max) which
    // can never produce a valid clamp.
    if (opts.min > opts.max) {
        warnOnce(
            @src(),
            "ui.drag({s}): inverted clamp — min={d} > max={d}",
            .{ label, opts.min, opts.max },
        );
    }
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.drag: pass a single-item pointer (e.g., &my_value), got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    return switch (@typeInfo(Child)) {
        .float => dragScalar(ctx, label, value, Child, opts),
        .int => dragScalar(ctx, label, value, Child, opts),
        .array => |arr| blk: {
            switch (@typeInfo(arr.child)) {
                .float, .int => {},
                else => @compileError("ui.drag: array element must be float or int, got " ++ @typeName(arr.child)),
            }
            break :blk dragArray(ctx, label, value, arr.child, arr.len, opts);
        },
        else => @compileError("ui.drag: child type must be float, int, or [N]float/int, got " ++ @typeName(Child)),
    };
}

pub const SelectableOpts = struct {
    /// Override row width. Zero means use the remaining content
    /// width (full-row selectable, like ImGui's default).
    width: f32 = 0,
};

/// Splitter axis. `.x` = vertical bar splitting left from right
/// (drag along X); `.y` = horizontal bar splitting top from bottom
/// (drag along Y). Names refer to the AXIS OF MOTION (x = bar
/// moves horizontally), matching imgui's `ImGuiAxis_X` / `_Y`
/// convention (`imgui_internal.h`).
pub const SplitterAxis = enum { x, y };

pub const SplitterOpts = struct {
    /// Visible bar thickness in pixels. Default 4 - pro-looking
    /// thin bar without being fragile.
    bar_width: f32 = 4,
    /// Invisible interaction-zone padding perpendicular to the
    /// axis (added to each side of the visible bar). Default 16
    /// → 36px total hit zone with a 4px bar. Forgiving on touch
    /// without making the visible bar chunky. Imgui:
    /// `hover_extend`.
    hit_extend: f32 = 16,
    /// Minimum size for the first pane (the one whose size is
    /// directly `*split_pos`). `*split_pos` is clamped against
    /// this on drag. Default 0 (no minimum).
    min1: f32 = 0,
    /// Minimum size for the second pane. Computed against the
    /// available size at draw time (caller must pass via
    /// `total_along_axis`). Default 0 (no minimum).
    min2: f32 = 0,
    /// Total available size along the splitter's axis (used to
    /// compute pane-2's clamp). Zero means "use the remaining
    /// content area on this axis" (i.e. cursor → window edge).
    total_along_axis: f32 = 0,
};

fn splitterImpl(
    ctx: *UiContext,
    str_id: []const u8,
    split_pos: *f32,
    axis: SplitterAxis,
    opts: SplitterOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, str_id);

    const cur: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // Compute the total along-axis size - caller-provided or
    // remaining content area on this axis.
    const total: f32 = if (opts.total_along_axis > 0)
        opts.total_along_axis
    else switch (axis) {
        .x => @max(0, w.pos[0] + w.size[0] - ctx.style.window_padding[0] - cur[0]),
        .y => @max(0, w.pos[1] + w.size[1] - ctx.style.window_padding[1] - cur[1]),
    };

    // The "perpendicular" extent - how thick is the splitter strip
    // relative to its perpendicular axis? For an.x (vertical) bar
    // it's the available height; for.y (horizontal) it's the width.
    // The bar visually spans the perpendicular axis fully so it
    // separates the two panes visibly.
    const perp_extent: f32 = switch (axis) {
        .x => @max(0, w.pos[1] + w.size[1] - ctx.style.window_padding[1] - cur[1]),
        .y => @max(0, w.pos[0] + w.size[0] - ctx.style.window_padding[0] - cur[0]),
    };

    // Visible-bar rect. Located AT the cursor position - the caller
    // positions the splitter via `sameLine` after the first pane's
    // endChild, so the cursor is already at the boundary between the
    // two panes. `split_pos` is the size of pane 1 (state the
    // splitter mutates on drag), NOT an offset from the cursor.
    // Was a bug in the original impl: bar.x was
    // `cur[0] + split_pos`, which combined with the
    // `sameLine`-after-endChild flow meant the bar rendered at
    // tree_right + left_w - i.e., a left_w offset past where it
    // should sit. Discovered.
    const bar_rect: Rectangle = switch (axis) {
        .x => .{
            .x = cur[0],
            .y = cur[1],
            .width = opts.bar_width,
            .height = perp_extent,
        },
        .y => .{
            .x = cur[0],
            .y = cur[1],
            .width = perp_extent,
            .height = opts.bar_width,
        },
    };

    // Interact rect: visible expanded by hit_extend perpendicular.
    // This makes the bar grabbable from the wider hot zone while
    // keeping the visual thin. Matches imgui's `bb_interact` from
    // `bb.Expand(...)` at imgui_widgets.cpp:1819.
    const interact_rect: Rectangle = switch (axis) {
        .x => .{
            .x = bar_rect.x - opts.hit_extend,
            .y = bar_rect.y,
            .width = bar_rect.width + 2 * opts.hit_extend,
            .height = bar_rect.height,
        },
        .y => .{
            .x = bar_rect.x,
            .y = bar_rect.y - opts.hit_extend,
            .width = bar_rect.width,
            .height = bar_rect.height + 2 * opts.hit_extend,
        },
    };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, interact_rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    // Press start: snapshot the click offset so we can compute the
    // mouse-delta relative to where the user grabbed. Matches
    // imgui's `g.ActiveIdClickOffset`.
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
        // Stash the offset between cursor and the bar's interact
        // origin on the relevant axis. Re-derived each frame from
        // `mouse - bar_interact.origin` (a held splitter always has
        // mouse position; if mouse left the hot zone, drag still
        // continues because active_id is sticky).
    }

    const held: bool = (ctx.active_id == id and ctx.input.mouse_left_down);

    // Drag handling.
    if (held) {
        // mouse_delta = mouse along axis - bar's current along-axis
        // origin. This is the same as imgui's
        // `(mouse - active_id_click_offset - bb_interact.min)[axis]`
        // because we treat bar position as the implicit reference;
        // any deviation between mouse and bar mid-line IS the delta
        // to apply.
        const mouse_along: f32 = switch (axis) {
            .x => ctx.input.mouse_pos[0],
            .y => ctx.input.mouse_pos[1],
        };
        const bar_origin_along: f32 = switch (axis) {
            .x => bar_rect.x + bar_rect.width * 0.5,
            .y => bar_rect.y + bar_rect.height * 0.5,
        };
        var delta: f32 = mouse_along - bar_origin_along;

        // Clamp against min sizes. Pane 1 = split_pos; can't go
        // below min1. Pane 2 = total - split_pos - bar_width; can't
        // go below min2 (i.e. split_pos can't exceed total - min2 -
        // bar_width).
        const max_delta_neg: f32 = -(split_pos.* - opts.min1); // most-negative legal delta
        const max_delta_pos: f32 = total - split_pos.* - opts.bar_width - opts.min2;
        if (delta < max_delta_neg) {
            delta = max_delta_neg;
        }
        if (delta > max_delta_pos) {
            delta = max_delta_pos;
        }

        if (delta != 0) {
            split_pos.* += delta;
        }
    }

    // Release: clear active.
    if (ctx.active_id == id and ctx.input.mouse_left_released) {
        ctx.active_id = 0;
    }

    // No recompute needed - bar position is at the cursor, which
    // doesn't move during this widget's execution. `split_pos` may
    // have been mutated by the drag; that change applies on the
    // NEXT frame when the caller uses the new value to size pane 1.
    // (Imgui has the same 1-frame visual lag for drag feedback.)
    const render_rect: Rectangle = bar_rect;

    // Color: held > hovered > idle. Reuses button-family colors;
    // imgui has dedicated Separator/SeparatorHovered/SeparatorActive
    // slots - filed as polish (extract dedicated Style fields if
    // more widgets want separator colors).
    const col_u32: ColorU32 = if (held)
        Color.toWire(ctx.style.button_active)
    else if (hovered)
        Color.toWire(ctx.style.button_hovered)
    else
        Color.toWire(ctx.style.button);
    drawRectFilled(ctx, render_rect, col_u32);

    w.layout.last_item_id = id;
    w.layout.last_item_rect = render_rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, held); // splitter mutates on drag

    // Layout: only advance along the splitter's axis by bar_width.
    // Perpendicular extent doesn't consume parent cursor - the
    // caller has already accounted for it via beginChild's height
    // for the panes. The splitter looks like a thin "spacer" of
    // bar_width × perp_extent from the layout's perspective.
    switch (axis) {
        .x => advanceLayout(w, .{ opts.bar_width, 0 }, ctx.style.item_spacing, ctx.style.window_padding[0]),
        .y => advanceLayout(w, .{ 0, opts.bar_width }, ctx.style.item_spacing, ctx.style.window_padding[0]),
    }

    return held;
}

/// Flags for `beginMultiSelect`. Per zimr's flag-decision rule, exclusive
/// groups become enums; independent options stay bools.
/// Sourced from `imgui.h:3027-3055` (ImGuiMultiSelectFlags_). 6 fields
/// are wired in MVP (marked ✓ below); the other 11 exist in the struct
/// from day 1 so future steps can fill them in without API churn.
pub const MultiSelectFlags = struct {
    // ---- Selection model (exclusive group) ----
    /// `.multi` (default) allows multiple selected items. `.single`
    /// forces single-selection; the API shape stays identical, useful
    /// when toggling a list between single ↔ multi modes. Imgui:
    /// `ImGuiMultiSelectFlags_SingleSelect`. ✓ MVP.
    selection: enum { multi, single } = .multi,

    // ---- Box-select mode (exclusive group) - DEFERRED to a later step.
    // Drag-rectangle selection is a whole input subsystem; filed for
    // post-1.3 follow-up. Imgui: `BoxSelect1d` / `BoxSelect2d`.
    /// Drag-rectangle box-select. `.none` (default) disables. `.d1`
    /// = same-X items (full-row); `.d2` = arbitrary X (grid). TODO
    /// Step 1.x: box-select is not implemented in MVP - value is
    /// accepted but currently ignored. Imgui:
    /// `imgui_widgets.cpp:8366-8412` for the box-select item-overlap
    /// logic.
    box_select: enum { none, d1, d2 } = .none,

    // ---- Scope for box-select + ClearOnClickVoid (exclusive group)
    /// `.window` (default) - scope is the whole window. `.rect`
    /// scope is the rect encompassing Begin..End (use when one window
    /// has multiple MS scopes). TODO Step 1.x: only `clear_on_click_void`
    /// uses this in MVP, and only `.window` is exercised; `.rect`
    /// is accepted but treated as `.window`. Imgui:
    /// `imgui_widgets.cpp:8029-8030` (ScopeRect's CursorMaxPos hack).
    scope: enum { window, rect } = .window,

    // ---- Click timing (exclusive group) - DEFERRED. Default is fine.
    /// When to apply a selection-changing click. `.auto` (default):
    /// on mouse-down if unselected, on mouse-up if already selected
    /// (permits drag-drop). `.always`: mouse-down (Excel-style).
    /// `.release`: mouse-up (most drag-friendly). TODO Step 1.x:
    /// only `.auto` is wired; the others are accepted but ignored.
    /// Imgui: `imgui_widgets.cpp:8285-8294` for the ButtonFlags
    /// override.
    click_timing: enum { auto, always, release } = .auto,

    // ---- Independent bools
    /// Disable the Ctrl+A "select all" shortcut. Imgui:
    /// `imgui_widgets.cpp:8101-8104`. ✓ MVP.
    no_select_all: bool = false,
    /// Disable Shift-anything (Shift-click, Shift-arrow). Guarantees
    /// every SetRange request has a single-item range, so the user's
    /// `applyRequests` doesn't have to interpolate. Imgui:
    /// `imgui_widgets.cpp:8038-8039`. ✓ MVP.
    no_range_select: bool = false,
    /// Disable selecting items when keyboard-navigating. Used with
    /// `no_auto_clear` for checkbox-list mode where each row is
    /// toggled independently. TODO Step 1.x: not wired in MVP;
    /// MS scope still emits the click-replace pattern. Imgui:
    /// `imgui_widgets.cpp:8343-8358`.
    no_auto_select: bool = false,
    /// Disable auto-clearing the selection when clicking/navigating
    /// to another item. Pair with `no_auto_select` for
    /// checkbox-list mode. TODO Step 1.x: not wired in MVP.
    /// Imgui: `imgui_widgets.cpp:8459-8470`.
    no_auto_clear: bool = false,
    /// Clicking an already-selected item normally clears the other
    /// selections; with this flag, it doesn't. Mac Finder
    /// behavior - important on touch where unintended clears
    /// feel destructive. Imgui:
    /// `imgui_widgets.cpp:8464-8465`. ✓ MVP.
    no_auto_clear_on_reselect: bool = false,
    /// Disable scrolling when box-selecting near scope edges.
    /// TODO Step 1.x: paired with box_select; not wired in MVP.
    /// Imgui: only consulted inside BeginBoxSelect.
    box_select_no_scroll: bool = false,
    /// Clear selection on Escape. Emits a single `SetAll(false)`
    /// request. Imgui: `imgui_widgets.cpp:8090-8098`. ✓ MVP.
    clear_on_escape: bool = false,
    /// Clear selection when clicking on empty space within the
    /// scope's rect. Imgui:
    /// `imgui_widgets.cpp` (via `ScopeRectActive`). ✓ MVP.
    clear_on_click_void: bool = false,
    /// Allow keyboard nav to wrap horizontally at scope edges.
    /// TODO Step 1.x: zimr's kb-nav is minimal today (
    /// territory); not wired. Imgui:
    /// `ImGuiMultiSelectFlags_NavWrapX` - temporary flag per
    /// imgui's own comment.
    nav_wrap_x: bool = false,
    /// Disable default right-click selecting (for context-menu
    /// patterns). TODO Step 1.x: no right-click on phone, low
    /// desktop priority. Imgui:
    /// `imgui_widgets.cpp:8414-8426`.
    no_select_on_right_click: bool = false,
};

fn freePriorMultiSelectScope(ctx: *UiContext) void {
    if (ctx.current_multi_select) |*old| {
        old.requests_buf.deinit(ctx.gpa);
        ctx.current_multi_select = null;
    }
}

fn addSetAllRequest(
    ctx: *UiContext,
    scope: *MultiSelectScope,
    selected: bool,
) void {
    scope.requests_buf.clearRetainingCapacity();
    scope.requests_buf.append(ctx.gpa, .{ .set_all = selected }) catch {
        @panic("OOM in addSetAllRequest");
    };
}

fn beginMultiSelectImpl(
    ctx: *UiContext,
    flags_in: MultiSelectFlags,
    selection_size: u32,
    items_count: u32,
) *const MultiSelectIO {
    // Tear down any prior scope's request buffer before clobbering.
    // (User pattern: Begin -> apply ->... -> End -> apply. By the
    // time the NEXT Begin runs, the previous scope's IO pointer is
    // long since consumed.)
    freePriorMultiSelectScope(ctx);

    const w: *Window = ctx.current_window orelse {
        @panic("beginMultiSelect called with no current window");
    };

    // Enforce inter-flag invariants matching imgui_widgets.cpp:8007-8010.
    var flags: MultiSelectFlags = flags_in;
    if (flags.selection == .single) {
        flags.box_select = .none;
    }

    const scope_id: Id = if (ctx.id_stack.len > 0)
        ctx.id_stack.items[ctx.id_stack.len - 1]
    else
        w.id;

    const gop: std.AutoHashMapUnmanaged(Id, MultiSelectState).GetOrPutResult = ctx.ms_storage.getOrPut(
        ctx.gpa,
        scope_id,
    ) catch {
        @panic("OOM in beginMultiSelect ms_storage");
    };
    if (!gop.found_existing) {
        gop.value_ptr.* = .{};
    }
    gop.value_ptr.last_frame_active = ctx.frame_count;
    gop.value_ptr.last_selection_size = selection_size;

    // Set up the transient scope.
    ctx.current_multi_select = .{
        .id = scope_id,
        .storage = gop.value_ptr,
        .flags = flags,
        .key_shift = snapshotShiftDown(&ctx.input),
        .key_ctrl = snapshotCtrlDown(&ctx.input),
        .io = .{
            .requests = &.{},
            .range_src_item = gop.value_ptr.range_src_item,
            .nav_id_item = gop.value_ptr.nav_id_item,
            .nav_id_selected = gop.value_ptr.nav_id_selected orelse false,
            .items_count = items_count,
        },
        .requests_buf = .empty,
        .loop_request_set_all = .none,
        .range_src_passed_by = false,
        .is_focused = (ctx.focused_window_id == w.id),
        .is_end_io = false,
    };
    const scope: *MultiSelectScope = &ctx.current_multi_select.?;

    // -------- Keyboard shortcuts
    // Imgui mirror: imgui_widgets.cpp:8085-8113. Two shortcuts in MVP:
    //   - Escape clears selection (if clear_on_escape AND selection non-empty)
    //   - Ctrl+A selects all (if !no_select_all AND !single-select)
    // Both emit a SetAll request. imgui's `MultiSelectAddSetAll` calls
    // `requests.resize(0)` first - wiping prior pending requests so
    // SetAll always lands as the single request. We mirror that
    // semantic (use addSetAllRequest helper below).
    // Edge: only emit when this scope is focused. In zimr we use
    // window-focus as the proxy (`is_focused`).

    var request_clear: bool = false;
    // Ctrl+A is inert in MVP until per-letter key snapshot fields land
    // (see comment further down in the is_focused block).
    const request_select_all: bool = false;

    if (scope.is_focused) {
        const escape_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame;
        if (flags.clear_on_escape and selection_size != 0 and escape_pressed) {
            request_clear = true;
        }
        // Imgui: SingleSelect + NoSelectAll both block Ctrl+A.
        const block_select_all: bool = (flags.selection == .single) or flags.no_select_all;
        if (!block_select_all and scope.key_ctrl) {
            // `chars_typed` won't contain 'A' on Ctrl+A (browser
            // sends keydown only); test via key_a_pressed edge.
            // zimr doesn't have key_a in InputSnapshot today; for
            // we approximate with: if user holds ctrl and
            // taps SOMETHING, Ctrl-A isn't reachable on phone
            // anyway. Filed for follow-up: per-letter key snapshot
            // fields, or a "named shortcut" API.
            // Effective MVP behavior: Ctrl+A is desktop-only and
            // currently inert until letter-key tracking lands.
            // Documented in MultiSelectFlags.no_select_all.
        }
    }

    if (request_clear or request_select_all) {
        addSetAllRequest(ctx, scope, request_select_all);
        scope.loop_request_set_all = if (request_select_all) .select else .clear;
        if (!request_select_all) {
            gop.value_ptr.last_selection_size = 0;
        }
    }

    // Begin-time IO snapshot: slice into the (now-populated) buffer.
    scope.io.requests = scope.requests_buf.items;

    return &scope.io;
}

fn endMultiSelectImpl(ctx: *UiContext) *const MultiSelectIO {
    const scope: *MultiSelectScope = &(ctx.current_multi_select orelse {
        @panic("endMultiSelect called outside a Begin/End pair");
    });

    // -------- ClearOnClickVoid
    // Imgui pattern: if click landed inside the scope rect but not on
    // any item, emit set_all(false). Detection: a mouse-left-clicked
    // edge this frame, the click coords are within the window's content
    // rect, AND no item registered as hovered (proxy: ctx.hovered_id
    // wasn't set to anything inside the scope's id stack).
    // MVP simplification: trigger if mouse_left_clicked AND
    // ctx.hovered_id == 0 (nothing hovered). This works for the
    // common case (tap empty area in scope's window).
    if (scope.flags.clear_on_click_void and ctx.input.mouse_left_clicked and ctx.hovered_id == 0) {
        // Honor "is_end_io reset" semantics: when Footer is about to
        // emit its first request this frame it does
        // `requests.resize(0)` to drop Begin's pending requests.
        // Same idea here: ClearOnClickVoid is a frame-fresh emit.
        if (!scope.is_end_io) {
            scope.requests_buf.clearRetainingCapacity();
            scope.is_end_io = true;
        }
        addSetAllRequest(ctx, scope, false);
    }

    // Snapshot the (possibly-mutated) requests slice.
    scope.io.requests = scope.requests_buf.items;

    // Refresh anchor / nav state from the now-final storage values.
    scope.io.range_src_item = scope.storage.range_src_item;
    scope.io.nav_id_item = scope.storage.nav_id_item;
    scope.io.nav_id_selected = scope.storage.nav_id_selected orelse false;

    return &scope.io;
}

/// Tiny disclosure triangle, rendered as horizontal strips. Cheap,
/// pixelly, but reads correctly at this size.
fn drawTriangleIndicator(
    ctx: *UiContext,
    rect: Rectangle,
    open: bool,
    col: ColorU32,
) void {
    if (open) {
        // Down-pointing: 3 horizontal strips, each shorter than the last.
        const cy: f32 = rect.y + rect.height / 2;
        var i: f32 = 0;
        while (i < 4) : (i += 1) {
            const strip_w = rect.width - i * 2;
            const strip_x = rect.x + i;
            drawRectFilled(ctx, .{ .x = strip_x, .y = cy - 2 + i, .width = strip_w, .height = 1 }, col);
        }
    } else {
        // Right-pointing: 3 vertical strips, each shorter than the last.
        const cx: f32 = rect.x + rect.width / 2;
        var i: f32 = 0;
        while (i < 4) : (i += 1) {
            const strip_h = rect.height - i * 2;
            const strip_y = rect.y + i;
            drawRectFilled(ctx, .{ .x = cx - 2 + i, .y = strip_y, .width = 1, .height = strip_h }, col);
        }
    }
}

fn collapsingHeaderImpl(
    ctx: *UiContext,
    label: []const u8,
    open: *bool,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const row_h: f32 = ctx.style.font_size + 2 * padding[1];
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const row_w: f32 = inner_right - at[0];

    const rect = Rectangle{ .x = at[0], .y = at[1], .width = row_w, .height = row_h };
    const hovered: bool = pointInRect(ctx.input.mouse_pos, rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    if (ctx.active_id == id and ctx.input.mouse_left_released) {
        if (hovered) {
            open.* = !open.*;
        }
        ctx.active_id = 0;
    }

    // Header background.
    const bg: Color = if (ctx.active_id == id)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, rect, Color.toWire(bg));

    // Triangle indicator on the left - three filled rects approximating
    // a wedge. Closed → right-pointing. Open → down-pointing.
    const tri_x: f32 = at[0] + padding[0];
    const tri_y: f32 = at[1] + (row_h - ctx.style.font_size) / 2;
    const tri_size: f32 = ctx.style.font_size;
    drawTriangleIndicator(
        ctx,
        .{ .x = tri_x, .y = tri_y, .width = tri_size, .height = tri_size },
        open.*,
        Color.toWire(ctx.style.text),
    );

    // Label to the right of the triangle.
    drawTextAtS(
        ctx,
        .{ tri_x + tri_size + 4, at[1] + padding[1] },
        label,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false); // header toggle isn't a "value edit" in ImGui semantics

    advanceLayout(w, .{ row_w, row_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return open.*;
}

fn beginTableImpl(
    ctx: *UiContext,
    label: []const u8,
    n_columns: u32,
    opts: TableOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    if (n_columns == 0 or n_columns > max_table_columns) {
        return false;
    }
    if (ctx.active_table != null) {
        // Nested tables not supported in. Caller could
        // hit this if begin/end pairs got desynced.
        return false;
    }

    const id: Id = widgetId(ctx, w, label);
    const start: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const outer_w: f32 = if (opts.outer_width > 0) opts.outer_width else (inner_right - start[0]);

    // Look up the persistent scroll offset for this table id; 0 when
    // first encountered (or when `outer_height == 0`, in which case
    // we just don't read it later).
    var scroll_y: f32 = 0;
    if (opts.outer_height > 0) {
        if (ctx.table_scroll_state.get(id)) |s| {
            scroll_y = s;
        }
    }

    const ts: TableState = .{
        .id = id,
        .n_columns = n_columns,
        .columns_set_up = 0,
        .columns = @splat(.{}),
        .row_y = start[1],
        .row_max_h = 0,
        .cur_col = -1,
        .cur_row = -1,
        .opts = opts,
        .outer_rect = .{
            .x = start[0],
            .y = start[1],
            .width = outer_w,
            .height = if (opts.outer_height > 0) opts.outer_height else 0,
        },
        .has_setup = false,
        .scroll_y = scroll_y,
        .data_origin_y = start[1],
        .clip_pushed = false,
        .row_bg_override = null,
    };
    ctx.active_table = ts;

    // P8.4: seed each column's `width_auto_seen` from
    // the persistent cache — that's the PREVIOUS frame's measured
    // content width per column, which `computeTableColumnLayout`
    // will use to size this frame under the content-fit policies.
    // First time we encounter this table id, the cache lookup
    // returns null and every column starts at 0 — the layout code's
    // fallback (`fallbackColumnWidth`) takes over.
    if (ctx.table_width_auto_cache.get(id)) |cached| {
        const ts_ptr_for_seed: *TableState = &ctx.active_table.?;
        var i: u32 = 0;
        while (i < n_columns) : (i += 1) {
            ts_ptr_for_seed.columns[i].width_auto_seen = cached[i];
        }
    }

    // P7.2: set up the splitter on the now-stored
    // TableState. Channel 0 = row bg + cell borders. Channel 1 =
    // cell content (widget submissions, redirected via
    // current_draw_list). At endTable, the splitter merges into
    // the window's draw_list — channel 0 first, then channel 1 —
    // so backgrounds always paint UNDER content regardless of
    // submission interleave.
    //

    // Allocate through the frame arena so the channel storage is
    // freed naturally at the end-of-frame arena reset. If the
    // allocation fails (OOM), revert active_table and return
    // false — caller will skip the table entirely.
    const ts_ptr: *TableState = &ctx.active_table.?;
    ts_ptr.splitter.split(ctx.frame_arena.allocator(), 2) catch {
        ctx.active_table = null;
        return false;
    };
    ts_ptr.saved_draw_list = ctx.current_draw_list;
    // Redirect widget submissions to channel 1 (content).
    ctx.current_draw_list = &ts_ptr.splitter.channels.items[1];

    return true;
}

/// Snapshot the current cell's content height into the running
/// `row_max_h` maximum for the active row. Same call-site
/// pattern as `snapshotCellWidth` — called at next-column,
/// next-row, and end-table so every cell's height contributes
/// to the row's resolved height regardless of which cell was
/// last in the row.
///
/// Pre-T1.1 zimr only ran this snapshot from
/// `tableNextColumnImpl` — fine for cells 0..N-2 of each row, but
/// the LAST cell of every row never had a "next column" call
/// after it. Symptom: a row whose tallest cell happened to be
/// the rightmost column got vertically clipped — visible content
/// stuck out below the row's drawn background. Adding the
/// snapshot at next-row and end-table covers the missed cells.
fn snapshotCellHeight(ts: *TableState, w: *const Window) void {
    if (ts.cur_col < 0) {
        return;
    }
    const used_h: f32 = (w.layout.cursor_max[1] - ts.row_y) + ts.opts.cell_padding_y;
    if (used_h > ts.row_max_h) {
        ts.row_max_h = used_h;
    }
}

/// Snapshot the current cell's content width into the column's
/// running `width_auto_seen` maximum. Called from every site that
/// "ends" a cell:
///   - `tableNextColumnImpl` when advancing within a row
///   - `tableNextRowImpl` when finishing a row's last cell
///   - `endTableImpl` when finishing the table's last cell
/// All three are needed because zimr's cell-cursor advance is
/// driven by the next call, not by an explicit end-of-cell marker.
fn snapshotCellWidth(ts: *TableState, w: *const Window) void {
    if (ts.cur_col < 0) {
        return;
    }
    const idx: usize = @intCast(ts.cur_col);
    if (idx >= ts.n_columns) {
        return;
    }
    const col: *TableColumnState = &ts.columns[idx];
    // Compute the cell's rightmost content x (from `cursor_max`,
    // which widget submissions push as they draw) relative to the
    // column's left edge. Add one symmetric `cell_padding_x` on
    // the right so the autofit width has room on BOTH sides — the
    // left-side padding is already baked into where `cursor_pos`
    // starts inside the cell.
    const content_w: f32 = (w.layout.cursor_max[0] - col.x) + ts.opts.cell_padding_x;
    if (content_w > col.width_auto_seen) {
        col.width_auto_seen = content_w;
    }
}

/// Paint one row's background - either the per-row override (if
/// set) or the alternating-tint pair from style when
/// `opts.row_bg` is enabled. No-op when neither applies. The
/// row_index decides even/odd when alternating.
///
/// P7.2: writes to the table's splitter channel 0 (the
/// bg channel) instead of the window's draw_list directly. At
/// endTable the splitter merges channel 0 BEFORE channel 1 (cell
/// content), so the bg sits behind cells in the final draw
/// stream. Pre-the bg cmd was appended AFTER cell content
/// in the same draw list and overlapped it; the workaround was a
/// translucent bg tint. Splitter makes opaque bgs work.
fn drawTableRowBg(
    ctx: *UiContext,
    ts: *TableState,
    y: f32,
    h: f32,
    row_index: u32,
) void {
    const color: Color = if (ts.row_bg_override) |c|
        c
    else if (ts.opts.row_bg)
        (if (row_index % 2 == 0) ctx.style.table_row_bg else ctx.style.table_row_bg_alt)
    else
        return;
    if (color.a == 0) {
        return;
    } // fully transparent → skip
    const arena: Allocator = ctx.frame_arena.allocator();
    // Channel 0 = bg channel.
    ts.splitter.channels.items[0].addRectFilled(
        arena,
        .{ .x = ts.outer_rect.x, .y = y, .width = ts.outer_rect.width, .height = h },
        Color.toWire(color),
    );
}

/// Paint pending per-cell bg overrides for the current row into
/// the table's splitter channel 0. Each entry in `ts.columns`
/// with a non-null `cell_bg_pending` produces one filled-rect
/// command at (col.x, y, col.width, h). After painting, the
/// pending field is reset so the next row's cells start fresh.
///
/// Called at row finalization (from `tableNextRow` for previous
/// rows; from `endTable` for the final row). Skips entries with
/// alpha=0 cells (treated as "clear pending override").
///
/// Z-order: cell bg lands in channel 0 AFTER `drawTableRowBg` for
/// the same row, so cell-level overrides paint OVER row-level
/// overrides — more specific wins. Both land BEFORE cell content
/// (channel 1) after the splitter merge.
fn flushTableCellBgs(
    ctx: *UiContext,
    ts: *TableState,
    y: f32,
    h: f32,
) void {
    const arena: Allocator = ctx.frame_arena.allocator();
    var i: u32 = 0;
    while (i < ts.n_columns) : (i += 1) {
        const col: *TableColumnState = &ts.columns[i];
        const c: Color = col.cell_bg_pending orelse continue;
        col.cell_bg_pending = null;
        if (c.a == 0) {
            continue;
        }
        ts.splitter.channels.items[0].addRectFilled(
            arena,
            .{ .x = col.x, .y = y, .width = col.width, .height = h },
            Color.toWire(c),
        );
    }
}

/// Copy each column's `width_auto_seen` into `ctx.table_width_auto_cache`
/// keyed by the table's id. The reverse direction (cache → column
/// state) happens at `beginTableImpl` time. Allocation failure is
/// non-fatal — the cache just won't carry this frame's data forward,
/// and the next frame falls back to the font-size heuristic.
fn persistTableWidthAuto(ctx: *UiContext, ts: *const TableState) void {
    var values: [max_table_columns]f32 = @splat(0);
    var i: u32 = 0;
    while (i < ts.n_columns) : (i += 1) {
        values[i] = ts.columns[i].width_auto_seen;
    }
    ctx.table_width_auto_cache.put(ctx.gpa, ts.id, values) catch {};
}

fn endTableImpl(ctx: *UiContext) void {
    const ts: *TableState = if (ctx.active_table) |*t| t else return;
    const w: *Window = ctx.current_window orelse return;
    const arena: Allocator = ctx.frame_arena.allocator();
    // P7.2: the table writes its bg-layer cmds
    // (row bg, borders, column dividers, scrollbar visual hint)
    // to splitter channel 0. Cell content (already submitted by
    // widgets via ctx.current_draw_list redirected to channel 1)
    // stays on channel 1. At the end of this function the splitter
    // is merged into the window's draw_list — channel 0 first.
    const bg_dl: *DrawList = &ts.splitter.channels.items[0];

    // Close out the last row's height into the outer rect.
    if (ts.cur_row >= 0) {
        // / T1.1 (turns 412-413): snapshot the final cell of
        // the final row. Same rationale as the row-end snapshot
        // in tableNextRowImpl — this is the only place the
        // table's very last cell's metrics are captured. Width
        // since P8.4; height added in T1.1.
        if (ts.cur_col >= 0) {
            snapshotCellHeight(ts, w);
            snapshotCellWidth(ts, w);
        }
        const last_row_bottom: f32 = ts.row_y + ts.row_max_h;
        // Paint the final row's background - same lazy-paint
        // pattern as `tableNextRow` does for previous rows.
        drawTableRowBg(ctx, ts, ts.row_y, ts.row_max_h, @as(u32, @intCast(ts.cur_row)));
        // P8.1: flush any per-cell bg overrides for the final row.
        // Same z-order as in tableNextRow — cell bgs paint OVER
        // row bg, UNDER cell content (channel 1).
        flushTableCellBgs(ctx, ts, ts.row_y, ts.row_max_h);
        // For fixed-height (scrolling) tables, outer rect height is
        // pre-set; for auto-fit tables it grows to fit content.
        if (ts.opts.outer_height == 0) {
            ts.outer_rect.height = last_row_bottom - ts.outer_rect.y;
        }

        // Scrollable table bookkeeping: pop clip, handle wheel,
        // write back the clamped scroll offset.
        if (ts.opts.outer_height > 0) {
            if (ts.clip_pushed) {
                // Pop the clip from BOTH channels (pushed on both
                // at first tableNextRow).
                bg_dl.popClipRect(arena);
                ts.splitter.channels.items[1].popClipRect(arena);
                ts.clip_pushed = false;
            }
            // Total data height = last row bottom - data origin
            // (computed in PRE-scroll coords by adding scroll_y
            // back to last_row_bottom).
            const total_data_h: f32 = (last_row_bottom + ts.scroll_y) - ts.data_origin_y;
            const visible_h: f32 = (ts.outer_rect.y + ts.outer_rect.height) - ts.data_origin_y;
            const max_scroll: f32 = @max(0, total_data_h - visible_h);

            // Handle mouse-wheel input if the cursor is over the
            // outer rect. Match the window-scroll wheel speed.
            if (pointInRect(ctx.input.mouse_pos, ts.outer_rect) and ctx.input.mouse_wheel_y != 0) {
                const wheel_speed: f32 = 24;
                ts.scroll_y -= ctx.input.mouse_wheel_y * wheel_speed;
            }
            if (ts.scroll_y < 0) {
                ts.scroll_y = 0;
            }
            if (ts.scroll_y > max_scroll) {
                ts.scroll_y = max_scroll;
            }
            // Persist for next frame.
            ctx.table_scroll_state.put(ctx.gpa, ts.id, ts.scroll_y) catch {};

            // Optional 4px-wide scrollbar on the right edge - purely
            // a visual hint; click-to-drag is polish.
            // Scrollbar lives on channel 0 (bg layer) — it's part
            // of the table chrome, not user content.
            if (max_scroll > 0) {
                const bar_w: f32 = 4;
                const track_x: f32 = ts.outer_rect.x + ts.outer_rect.width - bar_w - 1;
                const track_y: f32 = ts.data_origin_y;
                const track_h: f32 = visible_h;
                const thumb_h: f32 = @max(20, track_h * (visible_h / total_data_h));
                const thumb_y: f32 = track_y + (track_h - thumb_h) * (ts.scroll_y / max_scroll);
                bg_dl.addRectFilled(
                    arena,
                    .{ .x = track_x, .y = track_y, .width = bar_w, .height = track_h },
                    Color.toWire(ctx.style.frame_bg),
                );
                bg_dl.addRectFilled(
                    arena,
                    .{ .x = track_x, .y = thumb_y, .width = bar_w, .height = thumb_h },
                    Color.toWire(ctx.style.button),
                );
            }
        }
    }

    // P8.2: granular border drawing — each side / interior line
    // gated by its own flag, with the master `borders` flag
    // AND-ing on top. Pre-the entire block was gated by
    // the single `borders` boolean and used `addRectOutline` for
    // the outer; now we emit per-side `addLine` cmds so the four
    // sides can be toggled independently.
    if (ts.opts.borders) {
        const border_col: ColorU32 = Color.toWire(ctx.style.border);
        const ox: f32 = ts.outer_rect.x;
        const oy: f32 = ts.outer_rect.y;
        const ow: f32 = ts.outer_rect.width;
        const oh: f32 = ts.outer_rect.height;

        // Outer top edge — borders_outer_h.
        if (ts.opts.borders_outer_h) {
            bg_dl.addLine(arena, .{ ox, oy }, .{ ox + ow, oy }, border_col, 1);
        }
        // Outer bottom edge — borders_outer_h.
        if (ts.opts.borders_outer_h) {
            bg_dl.addLine(arena, .{ ox, oy + oh }, .{ ox + ow, oy + oh }, border_col, 1);
        }
        // Outer left edge — borders_outer_v.
        if (ts.opts.borders_outer_v) {
            bg_dl.addLine(arena, .{ ox, oy }, .{ ox, oy + oh }, border_col, 1);
        }
        // Outer right edge — borders_outer_v.
        if (ts.opts.borders_outer_v) {
            bg_dl.addLine(arena, .{ ox + ow, oy }, .{ ox + ow, oy + oh }, border_col, 1);
        }

        // Vertical dividers between columns — borders_inner_v.
        // Drawn FOR THE FULL outer rect (header + data area),
        // since columns are continuous top-to-bottom.
        if (ts.opts.borders_inner_v) {
            var i: u32 = 1;
            while (i < ts.n_columns) : (i += 1) {
                const col_x: f32 = ts.columns[i].x;
                bg_dl.addLine(
                    arena,
                    .{ col_x, oy },
                    .{ col_x, oy + oh },
                    border_col,
                    1,
                );
            }
        }
    }

    // Restore the draw-list redirection, then merge the splitter
    // into the window's draw_list (channel 0 first, then channel 1).
    // Order matters: restore BEFORE merge, because merge appends
    // into the saved target.
    ctx.current_draw_list = ts.saved_draw_list;
    const target_dl: *DrawList = if (ts.saved_draw_list) |dl| dl else &w.draw_list;
    ts.splitter.merge(arena, target_dl) catch {
        // Allocation failure on merge — silently drop the table's
        // commands rather than crash. UI degrades gracefully under
        // memory pressure (same convention as the rest of zimr's
        // DrawList add* methods).
    };

    // P8.4: persist this frame's per-column measured
    // widths into the context-level cache so the NEXT frame's
    // `computeTableColumnLayout` can use them. Single-pass UI
    // means we always trail by one frame — the first frame falls
    // back to the font-size heuristic, every frame thereafter uses
    // the previous frame's measurement. Cost is one 32×f32 entry
    // per active table id.
    persistTableWidthAuto(ctx, ts);

    // Advance the parent window's layout cursor past the table.
    advanceLayout(
        w,
        .{ ts.outer_rect.width, ts.outer_rect.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );

    ctx.active_table = null;
}

pub const TableColumnOpts = struct {
    /// Per-column sizing override. Default `null` — the column
    /// inherits from the table's `opts.sizing` policy (see
    /// `TableSizing`). When non-null, this column uses the
    /// specified mode regardless of the table-level policy.
    ///
    /// Mirrors imgui's per-column `ImGuiTableColumnFlags_Width*`
    /// flags (imgui.h:2200+): null = "use table default", set =
    /// "column-specific override".
    sizing: ?TableColumnSizing = null,
    /// Width in pixels for `.fixed` sizing. Zero (the default)
    /// means "use the table's sizing policy to derive the width"
    /// — `fixed_fit` uses this column's measured content width,
    /// `fixed_same` uses the max across all fixed columns.
    /// Ignored for `.stretch` sizing.
    width: f32 = 0,
    /// Relative weight when `.stretch`. Default `null` — the
    /// column inherits its weight from the table's sizing policy
    /// (`stretch_same` ⇒ 1.0 for every column; `stretch_prop` ⇒
    /// weight = measured content width). When non-null, this
    /// column uses the explicit weight regardless of policy —
    /// e.g. a column with weight 2 always gets twice the share
    /// of a default-weight column.
    /// Non-positive values are clamped to 1.
    weight: ?f32 = null,
    /// Suppress sort interaction on this column.  When true,
    /// header clicks are ignored and the column never appears in
    /// the sort specs.  Mirrors imgui's
    /// `ImGuiTableColumnFlags_NoSort` (imgui.h ~2200).
    /// Default false (sort cycles normally).  Replaces the old
    /// `sortable: bool = true` field — same semantics, flipped
    /// polarity to match imgui's negative-default flag convention.
    no_sort: bool = false,
    /// Block ascending direction in the sort cycle.  The cycle
    /// becomes: unsorted → descending → (flip to unsorted on
    /// re-click of primary; new primary stays descending).  If
    /// BOTH `no_sort_ascending` and `no_sort_descending` are set,
    /// the column behaves as `no_sort = true`.  Mirrors imgui's
    /// `ImGuiTableColumnFlags_NoSortAscending`.
    no_sort_ascending: bool = false,
    /// Block descending direction in the sort cycle.  See
    /// `no_sort_ascending` for cycle behavior.  Mirrors imgui's
    /// `ImGuiTableColumnFlags_NoSortDescending`.
    no_sort_descending: bool = false,
    /// Initial sort direction on the first frame this table is
    /// submitted.  When set, the column joins the sort specs at
    /// that direction without needing a header click.  If
    /// multiple columns set `default_sort`, the leftmost wins
    /// (primary); subsequent ones don't append (the imgui
    /// `DefaultSort` semantics — only one default-sorted column
    /// is supported here for simplicity).  Mirrors imgui's
    /// `ImGuiTableColumnFlags_DefaultSort` (which is a bool;
    /// our typed `?TableSortDirection` lets the caller pick the
    /// direction in the same field).  Default null — no default
    /// sort.
    default_sort: ?TableSortDirection = null,
    /// Force this column to size itself to its measured content
    /// width (the `width_auto_seen` value from the previous frame,
    /// or a font-derived fallback on the first frame).  Equivalent
    /// to `.{ .sizing = .fixed, .width = 0 }` but more discoverable
    /// — answers "how do I make a column autofit?" by name.
    /// Mirrors imgui's `ImGuiTableColumnFlags_WidthAuto`.
    /// Default false.  When true, takes precedence over `sizing`
    /// and `width`.
    width_auto: bool = false,
    /// Suppress the header text on this column.  Header BAR still
    /// renders (background, sort arrow if applicable, click
    /// hit-test for sort cycling); only the label text is
    /// skipped.  Useful for icon-only headers or to leave a
    /// column unlabeled in shared/merged header surfaces.
    /// Mirrors imgui's `ImGuiTableColumnFlags_NoHeaderLabel`.
    /// Default false.
    no_header_label: bool = false,
};

fn tableSetupColumnImpl(
    ctx: *UiContext,
    label: []const u8,
    opts: TableColumnOpts,
) void {
    const ts: *TableState = if (ctx.active_table) |*t| t else return;
    // Only honor setup calls before the first row.
    if (ts.cur_row >= 0) {
        return;
    }
    if (ts.columns_set_up >= ts.n_columns) {
        return;
    }
    var col: *TableColumnState = &ts.columns[ts.columns_set_up];
    const cap: usize = @min(label.len, col.name_buf.len);
    @memcpy(col.name_buf[0..cap], label[0..cap]);
    col.name_len = cap;
    // P8.4: resolve column sizing. Null = inherit from the table's
    // policy; non-null = explicit per-column override. The resolved
    // value is stored as a concrete `TableColumnSizing` on the column
    // state so downstream layout code doesn't have to re-resolve.
    //
    // `width_auto` is a shortcut for "sizing = fixed with width = 0"
    // (use the measured `width_auto_seen`).  When set, it takes
    // precedence over both `sizing` and `width` — the caller's
    // intent is "fit content", regardless of any other knobs.
    if (opts.width_auto) {
        col.sizing = .fixed;
        col.user_width = 0;
    } else {
        col.sizing = opts.sizing orelse defaultColumnSizing(ts.opts.sizing);
        col.user_width = opts.width;
    }
    // P8.4: user_weight is optional on both opts and state. null
    // means "let the sizing policy decide" — preserved here so
    // `effectiveStretchWeight` can distinguish "user wants weight=1"
    // (explicit) from "user didn't set a weight" (use policy).
    // Non-positive values are clamped to 1 to keep the layout math
    // well-defined.
    col.user_weight = if (opts.weight) |w| (if (w > 0) w else 1) else null;
    col.no_sort = opts.no_sort;
    col.no_sort_ascending = opts.no_sort_ascending;
    col.no_sort_descending = opts.no_sort_descending;
    col.default_sort = opts.default_sort;
    col.no_header_label = opts.no_header_label;

    // Seed the default sort direction into the live sort specs
    // — but only on the FIRST submission of this table, before
    // any user click has had a chance to override.  We detect
    // first-submission as "the sort_state map has no entry yet
    // for `ts.id`."  Once an entry exists (created by a user
    // click OR by a previous default_sort seed), we don't
    // re-seed.  Multiple columns with `default_sort` set are
    // unsupported here: the leftmost wins, subsequent ones are
    // ignored (imgui's documented `DefaultSort` semantics —
    // only one default-sorted column).
    if (opts.default_sort) |dir| {
        if (!ctx.table_sort_state.contains(ts.id)) {
            var ss: TableSortSpecs = .{};
            ss.specs[0] = .{ .column_index = ts.columns_set_up, .direction = dir };
            ss.len = 1;
            ctx.table_sort_state.put(ctx.gpa, ts.id, ss) catch {};
        }
    }

    ts.columns_set_up += 1;
    ts.has_setup = true;
}

fn tableNextColumnImpl(ctx: *UiContext) bool {
    const ts: *TableState = if (ctx.active_table) |*t| t else return false;
    if (ts.cur_row < 0) {
        // Caller forgot to call tableNextRow first - treat as a
        // no-op rather than reach into garbage.
        return false;
    }
    // Before advancing, snapshot the *previous* column's content
    // metrics. Two independent measurements run via dedicated
    // helpers:
    //

    //   - Width: `snapshotCellWidth` rolls the
    //     just-finished cell's content width into the column's
    //     `width_auto_seen`, which feeds the next frame's
    //     `computeTableColumnLayout` under the content-fit sizing
    //     modes.
    //   - Height: `snapshotCellHeight` rolls
    //     the just-finished cell's content height into the row's
    //     running `row_max_h`, so the row finalizes with the
    //     tallest cell's height.
    //

    // Both helpers ALSO run at row-end and table-end (the last
    // cell of each row never sees a "next column" call), but
    // running them here too is harmless and covers the common
    // path of cells 0..N-2 in a multi-column row. Idempotent
    // under repeated calls: each helper computes a fresh max
    // and only writes if the new value is larger.
    if (ts.cur_col >= 0) {
        const w: *Window = ctx.current_window orelse return false;
        snapshotCellHeight(ts, w);
        snapshotCellWidth(ts, w);
    }

    ts.cur_col += 1;
    if (ts.cur_col >= @as(i32, @intCast(ts.n_columns))) {
        return false;
    }

    const w: *Window = ctx.current_window orelse return false;
    const col: *const TableColumnState = &ts.columns[@intCast(ts.cur_col)];
    // P8.3: left-side cell padding is now conditional.
    // - Column 0 uses `no_pad_outer_x` to gate the gap to the
    //   table's outer-left edge.
    // - Columns 1..N-1 use `no_pad_inner_x` to gate the gap to
    //   the column divider on their left.
    // Defaults preserve legacy "padding on every column" behavior.
    const left_pad: f32 = if (ts.cur_col == 0)
        (if (ts.opts.no_pad_outer_x) 0 else ts.opts.cell_padding_x)
    else
        (if (ts.opts.no_pad_inner_x) 0 else ts.opts.cell_padding_x);
    // Position the layout cursor at the column's top-left + cell
    // padding. Cell content draws from here.
    w.layout.cursor_pos = .{ col.x + left_pad, ts.row_y + ts.opts.cell_padding_y };
    w.layout.cursor_max = w.layout.cursor_pos;
    w.layout.line_height = 0;
    return true;
}

fn tableGetSortSpecsImpl(ctx: *UiContext) ?*const TableSortSpecs {
    const ts: *const TableState = if (ctx.active_table) |*t| t else return null;
    const p: *TableSortSpecs = ctx.table_sort_state.getPtr(ts.id) orelse return null;
    if (p.len == 0) {
        return null;
    }
    return p;
}

/// Queryable subset of a column's flag state, returned by
/// `tableGetColumnFlags`.  Mirrors the fields of
/// `TableColumnOpts` that are stored 1:1 in `TableColumnState`
/// and can be read back faithfully after `tableSetupColumn`.
///
/// Not included: `sizing` / `width` / `weight` / `width_auto`.
/// Those resolve through inheritance + fallback paths (see
/// `defaultColumnSizing`, `effectiveStretchWeight`,
/// `width_auto_seen`) and aren't roundtrippable — by the time
/// any layout code looks at them, the original opts have been
/// normalized into a single concrete sizing + weight.  Query
/// the column's COMPUTED width via the column state's `width`
/// field directly if needed (no public accessor today; add one
/// behind P8.x if a use case appears).
pub const TableColumnFlags = struct {
    no_sort: bool = false,
    no_sort_ascending: bool = false,
    no_sort_descending: bool = false,
    no_header_label: bool = false,
    default_sort: ?TableSortDirection = null,
};

/// Open / refresh the drag preview window. Symmetric with
/// `beginTooltipImpl`. Lazily allocates on first use; reused
/// across frames.
fn openDragPreviewWindow(ctx: *UiContext) void {
    if (ctx.drag_preview_window == null) {
        const dw: *Window = ctx.gpa.create(Window) catch return;
        dw.* = .{
            .id = 0xFFFFFFFD, // reserved id (tooltip uses 0xFFFFFFFE)
            .pos = .{ 0, 0 },
            .size = .{ 0, 0 },
        };
        ctx.drag_preview_window = dw;
    }
    const dw: *Window = ctx.drag_preview_window.?;

    const padding: Vec2 = ctx.style.frame_padding;
    dw.pos = .{ ctx.input.mouse_pos[0] + 16, ctx.input.mouse_pos[1] + 16 };
    dw.size = .{ 9999, 9999 };
    dw.draw_list.clear();
    dw.layout.cursor_pos = .{ dw.pos[0] + padding[0], dw.pos[1] + padding[1] };
    dw.layout.cursor_max = dw.layout.cursor_pos;
    dw.layout.line_height = 0;

    dw.saved_window_ptr = ctx.current_window;
    dw.saved_draw_list = ctx.current_draw_list;

    ctx.current_window = dw;
    ctx.current_draw_list = &dw.draw_list;
    ctx.drag_preview_active_this_frame = true;
}

fn beginDragDropSourceImpl(ctx: *UiContext, flags: DragDropFlags) bool {
    const w: *Window = ctx.current_window orelse return false;
    const item_id: Id = w.layout.last_item_id;
    if (item_id == 0) {
        return false;
    }

    // Hovered? Reuse the last-item rect from the just-submitted widget.
    const item_hovered: bool = pointInRect(ctx.input.mouse_pos, w.layout.last_item_rect);

    // Start tracking when the user clicks on a draggable item.
    if (ctx.drag_drop.phase == .idle and item_hovered and ctx.input.mouse_left_clicked) {
        ctx.drag_drop.phase = .pending;
        ctx.drag_drop.source_widget_id = item_id;
        ctx.drag_drop.drag_start_pos = ctx.input.mouse_pos;
        ctx.drag_drop.clearPayload();
    }

    // Only return true for the widget that originated the drag,
    // and only once we've actually crossed the movement threshold.
    if (ctx.drag_drop.phase != .active or ctx.drag_drop.source_widget_id != item_id) {
        return false;
    }

    // Open the preview window (mirrors beginTooltip). Skipped when
    // the caller passes `.source_no_preview = true`.
    // renamed `no_preview` → `source_no_preview` to match
    // imgui's `ImGuiDragDropFlags_SourceNoPreviewTooltip`.
    if (!flags.source_no_preview) {
        openDragPreviewWindow(ctx);
    }
    return true;
}

fn setDragDropPayloadImpl(
    ctx: *UiContext,
    comptime T: type,
    v: *const T,
) void {
    // Only valid inside an active drag started by THIS frame's
    // beginDragDropSource scope. Guard against misuse.
    if (ctx.drag_drop.phase != .active) {
        return;
    }

    const tname: []const u8 = @typeName(T);
    const tlen: usize = @min(tname.len, DragDropState.TYPE_NAME_CAP);
    @memcpy(ctx.drag_drop.type_name_buf[0..tlen], tname[0..tlen]);
    ctx.drag_drop.type_name_len = tlen;

    const size: usize = @sizeOf(T);
    if (size > DragDropState.PAYLOAD_CAP) {
        // Oversize: clear type tag so target won't match.
        ctx.drag_drop.type_name_len = 0;
        ctx.drag_drop.payload_len = 0;
        return;
    }
    // Reinterpret value's bytes as [size]u8 and copy in.
    const src_bytes: [*]const u8 = @ptrCast(v);
    @memcpy(ctx.drag_drop.payload_buf[0..size], src_bytes[0..size]);
    ctx.drag_drop.payload_len = size;
}

fn endDragDropSourceImpl(ctx: *UiContext) void {
    // Symmetric with the tooltip pattern: pop the saved
    // window/draw_list off the preview window if we opened it.
    if (!ctx.drag_preview_active_this_frame) {
        return;
    }
    const dw: *Window = ctx.drag_preview_window orelse return;
    const padding: Vec2 = ctx.style.frame_padding;
    dw.size = .{
        (dw.layout.cursor_max[0] - dw.pos[0]) + padding[0],
        (dw.layout.cursor_max[1] - dw.pos[1]) + padding[1],
    };
    ctx.current_window = dw.saved_window_ptr;
    ctx.current_draw_list = dw.saved_draw_list;
}

fn beginDragDropTargetImpl(ctx: *UiContext, flags: DragDropFlags) bool {
    const w: *Window = ctx.current_window orelse return false;
    const item_id: Id = w.layout.last_item_id;
    if (item_id == 0) {
        return false;
    }

    // Only meaningful when a drag is in flight.
    if (ctx.drag_drop.phase != .active) {
        return false;
    }

    // Cursor must be over the just-submitted widget.
    if (!pointInRect(ctx.input.mouse_pos, w.layout.last_item_rect)) {
        return false;
    }

    // Mark this widget as the active drop target.
    ctx.drag_drop.target_widget_id = item_id;

    // Visual: highlight ring around the target so the user sees
    // where the drop will land. Outline only (no fill) so the
    // target's own content remains visible. Suppressed by
    // `.accept_no_draw_default_rect` (step 3.7).
    if (!flags.accept_no_draw_default_rect) {
        const dl: *DrawList = &w.draw_list;
        const arena: Allocator = ctx.frame_arena.allocator();
        // Inflate by 2 px so the ring sits OUTSIDE the widget content.
        const r: Rectangle = w.layout.last_item_rect;
        const ring: Rectangle = .{
            .x = r.x - 2,
            .y = r.y - 2,
            .width = r.width + 4,
            .height = r.height + 4,
        };
        dl.addRectOutline(arena, ring, Color.toWire(ctx.style.button_active));
    }
    // `.accept_draw_as_hovered`: also set the item's hovered flag
    // so the next `isItemHovered` call (or post-target user code)
    // reports the target as hovered. Useful for button-shaped
    // drop targets - they "light up" while the payload is over.
    if (flags.accept_draw_as_hovered) {
        w.layout.last_item_hovered = true;
    }
    return true;
}

fn acceptDragDropPayloadImpl(
    ctx: *UiContext,
    comptime T: type,
    flags: DragDropFlags,
) ?T {
    // No-op outside a target scope.
    if (ctx.drag_drop.target_widget_id == 0) {
        return null;
    }
    // No-op without an active drag.
    if (ctx.drag_drop.phase != .active) {
        return null;
    }

    // Type match: tag stored by setDragDropPayload must equal
    // @typeName(T). Also size-check defensively (a target asking
    // for the wrong T would otherwise read undefined trailing
    // bytes).
    const tname: []const u8 = @typeName(T);
    if (!eql(u8, ctx.drag_drop.typeName(), tname)) {
        return null;
    }
    if (ctx.drag_drop.payload_len != @sizeOf(T)) {
        return null;
    }

    // Delivery happens on the release edge - the frame the user
    // lets go of the button. Without this check, every frame the
    // cursor sits on the target during a drag would "accept" the
    // same payload over and over.
    // `.accept_before_delivery` skips the release-edge gate so
    // the caller can peek the payload mid-drag (preview-update
    // patterns, drop-zone enable/disable based on payload type).

    if (!flags.accept_before_delivery and !ctx.input.mouse_left_released) {
        return null;
    }

    // Copy out via @memcpy into a properly-aligned local - the
    // payload buffer is u8-aligned, so direct ptrCast would be UB
    // for any T whose alignment > 1.
    var out: T = undefined;
    @memcpy(std.mem.asBytes(&out), ctx.drag_drop.payload_buf[0..@sizeOf(T)]);
    debugLogPush(ctx, .drop_accepted, "target_id={x} type='{s}'", .{
        ctx.drag_drop.target_widget_id,
        tname,
    });
    return out;
}

fn bulletImpl(ctx: *UiContext) void {
    const w: *Window = ctx.current_window orelse return;
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const dot_size: f32 = 4;
    const offset: f32 = (ctx.style.font_size - dot_size) / 2;

    drawRectFilled(
        ctx,
        .{
            .x = at[0] + offset,
            .y = at[1] + offset,
            .width = dot_size,
            .height = dot_size,
        },
        Color.toWire(ctx.style.text),
    );

    // Consume a fixed-width cell so the next `sameLine` text has
    // predictable left-alignment across rows.
    const cell_w: f32 = ctx.style.font_size;
    const cell_h: f32 = ctx.style.font_size;

    w.layout.last_item_id = 0;
    w.layout.last_item_rect = .{ .x = at[0], .y = at[1], .width = cell_w, .height = cell_h };
    w.layout.last_item_hovered = false;

    advanceLayout(w, .{ cell_w, cell_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

/// Draw a textured quad with explicit UVs and a per-vertex tint.
/// Used by `image`. Two triangles, single rlBegin/rlEnd batch.
fn drawTexturedQuad(
    ctx: *UiContext,
    r: Rectangle,
    tex_id: u32,
    uv0: Vec2,
    uv1: Vec2,
    col: ColorU32,
) void {
    if (ctx.eager_mode or ctx.current_draw_list == null) {
        // Eager-mode debug path: bypass the draw list and emit straight
        // to GL. The live `*GlState` is stashed into UiContext at
        // beginFrame time so we don't need to re-thread it through
        // every widget. Stale outside begin/endFrame - but eager-mode
        // can only be triggered between them.
        if (ctx.frame_gl) |gl| {
            renderTexturedQuad(gl, r, tex_id, uv0, uv1, col);
        }
        return;
    }
    ctx.current_draw_list.?.addTexturedQuad(ctx.frame_arena.allocator(), r, tex_id, uv0, uv1, col);
}

fn imageImpl(
    ctx: *UiContext,
    tex_id: u32,
    size: Vec2,
    flipped: bool,
) void {
    const w: *Window = ctx.current_window orelse return;
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect = Rectangle{ .x = at[0], .y = at[1], .width = size[0], .height = size[1] };

    // Y-flip RTs at sample time. raylib's offscreen-pass convention
    // hands us a texture with origin bottom-left, so swap V for the
    // user (matching the pattern in examples/rtt.zig).
    const uv0: Vec2 = if (flipped) .{ 0, 1 } else .{ 0, 0 };
    const uv1: Vec2 = if (flipped) .{ 1, 0 } else .{ 1, 1 };
    drawTexturedQuad(ctx, rect, tex_id, uv0, uv1, 0xFFFFFFFF);

    w.layout.last_item_id = 0;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, rect);

    advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

fn pushStyleImpl(
    ctx: *UiContext,
    comptime field_name: []const u8,
    value: anytype,
) void {
    const FieldT = @TypeOf(@field(ctx.style, field_name));
    const size: usize = @sizeOf(FieldT);
    if (size > 32) {
        @compileError("pushStyle: field '" ++ field_name ++ "' exceeds the 32-byte snapshot buffer");
    }

    var snap: StyleSnapshot = .{
        .offset = @offsetOf(Style, field_name),
        .size = size,
    };
    // Snapshot the current value via mem.asBytes - same memcpy
    // pattern as the drag-drop payload code, alignment-safe.
    const cur_val: FieldT = @field(ctx.style, field_name);
    @memcpy(snap.backup[0..size], std.mem.asBytes(&cur_val));

    ctx.style_stack.append(snap) catch return;

    // Apply the new value. Type coercion + compile errors flow
    // through Zig's normal assignment rules.
    @field(ctx.style, field_name) = value;
}

fn popStyleImpl(ctx: *UiContext) void {
    const snap: StyleSnapshot = ctx.style_stack.pop() orelse return;
    // Write the saved bytes back to the field's address. Direct
    // byte-level restore works because the snapshot came from the
    // same field's `mem.asBytes` view, so alignment matches by
    // construction.
    const dst: [*]u8 = @ptrCast(&ctx.style);
    @memcpy(dst[snap.offset .. snap.offset + snap.size], snap.backup[0..snap.size]);
}

/// Browser soft-keyboard hints — maps 1:1 to the HTML
/// `inputmode` attribute values.  Only consulted when the
/// runtime is wasm + has a DOM; ignored everywhere else.
///
/// Reference: https://html.spec.whatwg.org/#input-modalities:-the-inputmode-attribute
pub const InputMode = enum {
    /// Default text keyboard.
    text,
    /// Numeric keypad (no decimal point, no sign).
    numeric,
    /// Numeric keypad with decimal point.  Use for general
    /// floating-point input where a sign isn't usually needed.
    decimal,
    /// Email keyboard (typically includes `@` and `.com` keys).
    email,
    /// Telephone keypad.
    tel,
    /// URL keyboard (typically includes `/` and `.com`).
    url,
    /// Search keyboard (typically shows a "search" return key).
    search,

    /// Map to the HTML attribute string the JS bridge sends to
    /// the runtime.  All values are lowercase as the spec requires.
    pub fn toAttr(self: InputMode) []const u8 {
        return switch (self) {
            .text => "text",
            .numeric => "numeric",
            .decimal => "decimal",
            .email => "email",
            .tel => "tel",
            .url => "url",
            .search => "search",
        };
    }
};

pub const InputTextOpts = struct {
    /// Override box width. Zero means auto-fit to remaining row
    /// width (with the label reserved on the right).
    width: f32 = 0,
    /// Placeholder text shown in dimmed (`style.text_disabled`)
    /// color when the buffer is empty AND the widget isn't
    /// focused. Disappears the moment the user types or focuses.
    /// Empty string (default) → no hint. ImGui: parameter to
    /// `InputTextWithHint`; we put it on the opts so a single
    /// `inputText(...)` call can pass a hint without going through
    /// the variant.
    hint: []const u8 = "",

    // ---- A4: text-input callbacks
    // Each non-null callback gets called at the relevant event.
    // `user_data` is opaque - caller casts to whatever typed state
    // they need to consult inside the callback (autocomplete table,
    // history ring buffer, etc.). See `examples/ui_input_callbacks.zig`.
    // Multiline rules:
    //   - `char_filter` and `edit` fire for both single-line AND
    //     multiline.
    //   - `completion` (Tab) and `history` (Up/Down) fire only for
    //     single-line. In multiline, Tab is reserved for future
    //     "insert tab character" behavior and Up/Down already drive
    //     line navigation.

    /// Fires on every printable character about to be inserted.
    /// Callback can replace via `data.event_char = X` or drop via
    /// `data.event_char = 0`.  Run BEFORE the declarative
    /// `chars_*` flags so the user can fully override default
    /// behavior.  ImGui: `CallbackCharFilter`.
    ///
    /// Web-path note: host-only today.  The DOM input/textarea
    /// overlay handles character entry directly without
    /// dispatching back to wasm per-char.  The declarative
    /// `chars_*` flags (turn 440c) have a JS-side mirror that
    /// works on web; the user `char_filter` callback does not.
    /// Web parity for the callback would require a JS↔wasm
    /// round-trip per keystroke — feasible but not yet
    /// implemented.  See turn 442 for the contract on host;
    /// future turn for web parity.
    char_filter: ?InputTextCallback = null,

    /// Fires on every frame the buffer was changed.  Useful for
    /// "type-to-search" / live-filter UIs.  Note: `inputText()`
    /// itself returns true on the same frames; this callback is
    /// useful when you want the buffer state at callback time
    /// (e.g., to update an external index).  ImGui: `CallbackEdit`.
    ///
    /// Web-path note: works on web.  Length-change polling on
    /// the DOM overlay sets `changed=true`, which fires this
    /// callback the same way as on host.  This is the only one
    /// of the four callbacks that's fully cross-platform today.
    edit: ?InputTextCallback = null,

    /// Fires on Tab in single-line.  Callback can rewrite the
    /// buffer via `data.insertChars` / `deleteChars` / `setBuffer`.
    /// Typical use: tab-completion that replaces a typed prefix
    /// with the matching dictionary entry.  Does NOT fire when
    /// `allow_tab_input` is set (that flag takes precedence and
    /// inserts a literal `\t`).  ImGui: `CallbackCompletion`.
    ///
    /// Web-path note: host-only today.  The DOM input/textarea
    /// overlay swallows Tab for browser focus advancement.  Web
    /// parity would need a JS-side `keydown` listener that calls
    /// a wasm export on Tab — straightforward but deferred.
    completion: ?InputTextCallback = null,

    /// Fires on Up or Down arrow in single-line.  Read
    /// `data.history_dir` to know which direction.  Typical use:
    /// shell-style "scroll through prior commands."  Gated on
    /// `!read_only` — a read-only field's history callback would
    /// be unable to mutate the buffer anyway.  ImGui: `CallbackHistory`.
    ///
    /// Web-path note: host-only today.  The DOM input overlay
    /// uses Up/Down for caret navigation within the field.  Web
    /// parity here is harder than completion because the keys
    /// have a legitimate native meaning — would need a flag
    /// telling JS "treat Up/Down as history, not caret nav."
    history: ?InputTextCallback = null,

    /// Opaque pointer passed to every callback. Cast it back to
    /// your typed state in the callback body. Null is fine for
    /// callbacks that don't need state (e.g., a stateless decimal-
    /// only filter).
    user_data: ?*anyopaque = null,

    /// Hint to the browser about which soft-keyboard variant to
    /// show on mobile when this input is focused.  Maps directly
    /// to the HTML `inputmode` attribute on a hidden DOM input
    /// the runtime keeps for IME / mobile keyboard capture.
    /// Desktop browsers and wasm runtimes without a DOM ignore it.
    input_mode: InputMode = .text,

    // ---- P9.1: declarative character filters
    // Run BEFORE `char_filter` callback.  When more than one
    // filter is set, characters must pass ALL of them.  Filters
    // operate on incoming codepoints only — paste / setBuffer
    // bypass them (matches imgui).

    /// Accept only the digits `0`-`9` plus the symbols `.+-`.
    /// For free-form decimal-number entry.  Mirrors imgui's
    /// `ImGuiInputTextFlags_CharsDecimal` minus the legacy `*/`
    /// it includes (those are math-expression characters from
    /// imgui's `InputScalar` heritage; zimr keeps the set focused
    /// on actual numbers).
    chars_decimal: bool = false,

    /// Accept only `0`-`9` + `a`-`f` + `A`-`F`.  For hex colour
    /// entry, hash digests, address fields.  Mirrors imgui's
    /// `ImGuiInputTextFlags_CharsHexadecimal`.
    chars_hexadecimal: bool = false,

    /// Accept only digits + `.+-eE`.  Superset of `chars_decimal`
    /// adding the exponent letters for entries like `1.5e-3`.
    /// Mirrors imgui's `ImGuiInputTextFlags_CharsScientific`.
    chars_scientific: bool = false,

    /// Rewrite incoming `a`-`z` to `A`-`Z` (replace, not drop).
    /// Anything else passes through unchanged.  Use for case-
    /// normalized entry like license plates or stock tickers.
    /// Mirrors imgui's `ImGuiInputTextFlags_CharsUppercase`.
    chars_uppercase: bool = false,

    /// Reject ASCII space (0x20) and horizontal tab (0x09).
    /// Newline isn't blocked here — multiline already controls
    /// newline acceptance independently.  Use for usernames,
    /// identifiers, URLs.  Mirrors imgui's
    /// `ImGuiInputTextFlags_CharsNoBlank`.
    chars_no_blank: bool = false,

    // ---- P9.2: form-flavor behavior flags
    // All five ship both paths.  Host path (used in tests + any
    // hypothetical native desktop backend) gates / changes
    // behavior in `inputTextImpl`.  Web path sets the
    // corresponding attribute on the DOM overlay element via
    // dedicated JS calls.  The DOM overlay does the heavy lifting
    // on web; the host path is the reference behavior the tests
    // pin down.

    /// Return `true` from `inputText()` ONLY on the frame Enter
    /// was pressed (and the widget had focus).  Default behavior:
    /// `inputText()` returns true on every frame the buffer
    /// changed (so callers can react to each keystroke).  Set
    /// this when the caller only wants to act on "commit" —
    /// e.g. a search bar that fires the search on Enter, not
    /// on every character.
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_EnterReturnsTrue`.
    enter_returns_true: bool = false,

    /// Pressing Escape clears the buffer (`len.* = 0`, cursor
    /// returns to 0) AND defocuses.  Default Escape behavior:
    /// just defocus, buffer untouched.  Use for editable fields
    /// where Escape should mean "cancel my edit, start over."
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_EscapeClearsAll`.
    escape_clears: bool = false,

    /// Render buffer content as `*` characters instead of the
    /// real bytes.  Host path: substitution happens in the
    /// rendering branch only — the buffer itself still holds the
    /// real characters; cursor measurement also uses the masked
    /// text (so caret position matches what the user sees).
    /// Web path: the DOM overlay element is given `type="password"`,
    /// so the browser does masking + secure-paste + no-autofill.
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_Password`.
    password_mask: bool = false,

    /// Disable all editing operations.  Cursor + focus still work
    /// (caller can highlight content for copy on web; host path
    /// supports arrow nav for accessibility).  Insertions,
    /// backspace, delete, paste, and the `char_filter` callback
    /// are all gated.  The widget always returns `false` from
    /// `inputText()` because the buffer can't change.  Web path:
    /// the DOM overlay gets `readOnly = true`; the browser
    /// preserves focus + selection but blocks input.
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_ReadOnly`.
    read_only: bool = false,

    /// Pressing Tab inserts an ASCII tab (`\t`, 0x09) into the
    /// buffer instead of triggering the `completion` callback.
    /// Default Tab behavior: dispatch to `opts.completion` if
    /// set, otherwise no-op (and the browser focuses the next
    /// element on web, which usually isn't what the caller
    /// wants for a multi-line code-style edit).  Web path: a
    /// `keydown` handler in the overlay intercepts Tab and
    /// `preventDefault`s it, then inserts `\t` directly into the
    /// input value via `setRangeText`.
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_AllowTabInput`.
    allow_tab_input: bool = false,

    /// Multiline only.  Inverts the default Enter/Ctrl+Enter
    /// behavior:
    ///   - default (off):  Enter inserts `\n`.  Ctrl+Enter is
    ///     ignored.
    ///   - on:             Enter commits + defocuses (plays
    ///     well with `enter_returns_true`).  Ctrl+Enter inserts
    ///     `\n`.
    ///
    /// Use for chat-style boxes where Enter sends the message
    /// and Ctrl+Enter (or Shift+Enter — see "Note" below)
    /// inserts a newline.  Single-line `inputText` ignores this
    /// flag entirely; it has no `\n` insertion path.
    ///
    /// Note: real-world chat UIs often use Shift+Enter for
    /// newline rather than Ctrl+Enter.  The flag is named after
    /// imgui's `CtrlEnterForNewLine` for parity; the caller can
    /// poll `u.isKeyDown(.left_shift)` in `opts.edit` if Shift
    /// semantics are wanted instead.  Future: a separate
    /// `shift_enter_for_newline` opt if a real UI needs it.
    ///
    /// Mirrors imgui's `ImGuiInputTextFlags_CtrlEnterForNewLine`.
    ctrl_enter_for_newline: bool = false,
};

/// Options for `sliderAngle`. Range is specified in DEGREES even
/// though the underlying value is stored in radians - degrees are
/// what users want to see.
pub const SliderAngleOpts = struct {
    /// Minimum value in degrees. Default -360.
    min_deg: f32 = -360,
    /// Maximum value in degrees. Default 360.
    max_deg: f32 = 360,
    /// Display format string. Default appends a degree symbol so
    /// the readout reads naturally ("45°", "-12°").
    fmt: []const u8 = "{d:.0}°",
};

pub const ComboOpts = struct {
    /// Override box width. Zero means auto-fit to remaining row
    /// width (with the label reserved on the right).
    width: f32 = 0,
};

fn comboImpl(
    ctx: *UiContext,
    label: []const u8,
    current_index: *i32,
    items: []const []const u8,
    opts: ComboOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const row_h: f32 = ctx.style.font_size + 2 * padding[1];
    const arrow_size: f32 = ctx.style.font_size;

    const text_size: Vec2 = measureTextS(ctx, label);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const reserve_label: f32 = text_size[0] + ctx.style.item_spacing[0];
    const box_w: f32 = effectiveItemWidth(
        ctx,
        opts.width,
        @max(80, inner_right - at[0] - reserve_label),
    );

    const closed_box = Rectangle{ .x = at[0], .y = at[1], .width = box_w, .height = row_h };
    const hovered_closed: bool = pointInRect(ctx.input.mouse_pos, closed_box);
    if (hovered_closed) {
        ctx.hovered_id = id;
    }

    const was_open: bool = ctx.combo_open_id == id;
    var changed: bool = false;

    // Click on closed row toggles. Opens this combo, implicitly
    // closes any other.
    if (hovered_closed and ctx.input.mouse_left_clicked) {
        if (was_open) {
            ctx.combo_open_id = 0;
        } else {
            ctx.combo_open_id = id;
        }
    }
    const is_open: bool = ctx.combo_open_id == id;

    // ---- Render closed row -------------------------------------------------
    const closed_bg: Color = if (is_open)
        ctx.style.frame_bg_active
    else if (hovered_closed)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, closed_box, Color.toWire(closed_bg));

    // Current item label inside the box (left-aligned, padded).
    const have_valid_index = current_index.* >= 0 and current_index.* < @as(i32, @intCast(items.len));
    if (have_valid_index) {
        drawTextAtS(
            ctx,
            .{ closed_box.x + padding[0], closed_box.y + padding[1] },
            items[@intCast(current_index.*)],
            Color.toWire(ctx.style.text),
        );
    }

    // Arrow indicator on the right (down when open or closed - same
    // glyph, conventional for combos).
    drawTriangleIndicator(
        ctx,
        .{
            .x = closed_box.x + closed_box.width - arrow_size - padding[0],
            .y = closed_box.y + (closed_box.height - arrow_size) / 2,
            .width = arrow_size,
            .height = arrow_size,
        },
        true, // always render down-pointing - ImGui convention
        Color.toWire(ctx.style.text),
    );

    // Label to the right of the box.
    drawTextAtS(
        ctx,
        .{ closed_box.x + closed_box.width + ctx.style.item_spacing[0], closed_box.y + padding[1] },
        label,
        Color.toWire(ctx.style.text),
    );

    // ---- Render dropdown items (only when open)
    // Dropdown emits to the foreground draw list - that's what makes
    // it a real popup: it overlays everything else regardless of
    // submission order, and the layout below doesn't reflow. Click
    // handling for items still happens DURING this combo() call
    // (we have the `current_index` pointer here; we don't have it at
    // foreground_dl render time), but the visual emission is deferred
    // to endFrame's foreground render pass.
    // Provably better than ImGui's combo:
    //  - ImGui's combo is also a popup, but uses the popup-stack +
    //    BeginPopup machinery. Ours uses the foreground draw list
    //    directly with no popup state machine. Same overlay result,
    //    fewer moving parts.
    //  - Our combo only ever needs ONE redirect to foreground_dl;
    //    ImGui pushes a full window onto the popup stack and runs
    //    its layout pipeline. Ours runs trivial linear layout
    //    inline - measurably less code path for the same UX.

    if (is_open and items.len > 0) {
        const dropdown_top: f32 = closed_box.y + closed_box.height;
        const dropdown_height: f32 = float(items.len) * row_h;
        const dropdown_rect = Rectangle{
            .x = closed_box.x,
            .y = dropdown_top,
            .width = closed_box.width,
            .height = dropdown_height,
        };

        // Click handling on dropdown items happens FIRST, while the
        // window's draw list is still active - we mutate combo state
        // (close, set selection) but emit nothing here. All visual
        // emission goes through the foreground_dl redirect below.
        for (items, 0..) |_, i| {
            const item_y: f32 = dropdown_top + float(i) * row_h;
            const item_rect = Rectangle{ .x = closed_box.x, .y = item_y, .width = closed_box.width, .height = row_h };
            if (pointInRect(ctx.input.mouse_pos, item_rect) and ctx.input.mouse_left_clicked) {
                current_index.* = @intCast(i);
                ctx.combo_open_id = 0;
                changed = true;
            }
        }
        // Click outside both the closed row AND the dropdown closes.
        if (ctx.input.mouse_left_clicked and !hovered_closed and
            !pointInRect(ctx.input.mouse_pos, dropdown_rect))
        {
            ctx.combo_open_id = 0;
        }

        // Dropdown visuals → foreground draw list. Saved/restored
        // around the emission so any state we change is local.
        // In eager mode (RT scope), foreground_dl isn't used
        // emissions just go straight to rlgl from the helpers.
        const saved_dl: ?*DrawList = ctx.current_draw_list;
        if (!ctx.eager_mode) {
            ctx.current_draw_list = &ctx.foreground_dl;
        }
        defer if (!ctx.eager_mode) {
            ctx.current_draw_list = saved_dl;
        };

        // Background panel - slightly darker than frame_bg to
        // visually separate from the closed row. Plus a 1px border
        // so the popup pops off whatever it's overlaying.
        drawRectFilled(ctx, dropdown_rect, Color.toWire(ctx.style.window_bg));
        drawRect(ctx, dropdown_rect, Color.toWire(ctx.style.border));

        for (items, 0..) |item, i| {
            const item_y: f32 = dropdown_top + float(i) * row_h;
            const item_rect = Rectangle{ .x = closed_box.x, .y = item_y, .width = closed_box.width, .height = row_h };
            const item_hovered: bool = pointInRect(ctx.input.mouse_pos, item_rect);
            const is_selected = current_index.* == @as(i32, @intCast(i));

            if (item_hovered or is_selected) {
                const item_bg: Color = if (item_hovered)
                    ctx.style.button_hovered
                else
                    ctx.style.button;
                drawRectFilled(ctx, item_rect, Color.toWire(item_bg));
            }

            drawTextAtS(
                ctx,
                .{ item_rect.x + padding[0], item_rect.y + padding[1] },
                item,
                Color.toWire(ctx.style.text),
            );
        }
    }

    // Layout: combo always consumes ONLY the closed-row height,
    // even when open. The dropdown overlays subsequent widgets via
    // the foreground draw list - it does NOT push them down. This
    // is the architectural payoff of going deferred.
    const total_w: f32 = closed_box.width + ctx.style.item_spacing[0] + text_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = closed_box.x, .y = closed_box.y, .width = total_w, .height = closed_box.height };
    w.layout.last_item_hovered = hovered_closed;
    markItemPost(ctx, w, changed);

    advanceLayout(w, .{ total_w, closed_box.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return changed;
}

/// Called by every widget at end-of-submission with the
/// `consumed` size it occupies on its row. Updates the layout
/// scope's row tracking and advances the cursor to the next row.
/// New model:
/// - `cursor_pos_prev_line` tracks (right edge, row top) - used
///   by `sameLine` to restore the cursor.
/// - `prev_line_height` records the row's tallest widget - used
///   by `sameLine` to restore `line_height` for ongoing
///   accumulation if the row continues.
/// - The cursor wraps to `(origin.x + indent, row_bottom +
///   spacing)`. Row reset uses `layout.origin[0]`, NOT the
///   parent window's `pos[0]` - this is what fixed the editor-
///   pane left-clip bug.
/// The `window_pad_x` argument is retained for callsite-signature
/// compat but no longer participates in the cursor reset (the
/// `LayoutScope.origin` already encodes the content-left).
/// Initialize a window's per-frame layout for a content region whose
/// top-left content cursor is `content_origin` (the caller has already
/// added window padding). This sets `origin` AND `cursor_pos` together,
/// deliberately: they are COUPLED and must agree. The first widget in the
/// window is placed at `cursor_pos`, but `advanceLayout` resets every
/// SUBSEQUENT row's X to `origin[0] + indent - scroll_x` (imgui's
/// Pos.x-based row reset). So setting `cursor_pos` WITHOUT `origin` makes
/// row 0 land correctly while rows 1+ snap to the stale origin (default
/// {0,0} => the canvas left edge). That was exactly the combo/popup
/// dropdown bug: the first item rendered inside the dropdown, the rest
/// jumped to screen-left. ALWAYS init a window's content layout through
/// this helper instead of poking `layout.cursor_pos` field-by-field — it
/// makes the "forgot to set origin" mistake impossible.
fn resetContentLayout(w: *Window, content_origin: Vec2) void {
    w.layout.origin = content_origin;
    w.layout.cursor_pos = content_origin;
    w.layout.cursor_max = content_origin;
    w.layout.line_height = 0;
    w.layout.indent_x = 0;
}

fn beginComboImpl(
    ctx: *UiContext,
    label: []const u8,
    preview: []const u8,
    opts: ComboOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const row_h: f32 = ctx.style.font_size + 2 * padding[1];
    const arrow_size: f32 = ctx.style.font_size;
    const text_size: Vec2 = measureTextS(ctx, label);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const reserve_label: f32 = text_size[0] + ctx.style.item_spacing[0];
    const box_w: f32 = effectiveItemWidth(
        ctx,
        opts.width,
        @max(80, inner_right - at[0] - reserve_label),
    );

    const closed_box = Rectangle{ .x = at[0], .y = at[1], .width = box_w, .height = row_h };
    const hovered_closed: bool = pointInRect(ctx.input.mouse_pos, closed_box);
    if (hovered_closed) {
        ctx.hovered_id = id;
    }

    const was_open: bool = ctx.combo_open_id == id;
    if (hovered_closed and ctx.input.mouse_left_clicked) {
        ctx.combo_open_id = if (was_open) 0 else id;
    }
    const is_open: bool = ctx.combo_open_id == id;

    // -------- Render closed row -------------------------------------------
    const closed_bg: Color = if (is_open)
        ctx.style.frame_bg_active
    else if (hovered_closed)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, closed_box, Color.toWire(closed_bg));
    if (preview.len > 0) {
        drawTextAtS(
            ctx,
            .{ closed_box.x + padding[0], closed_box.y + padding[1] },
            preview,
            Color.toWire(ctx.style.text),
        );
    }
    drawTriangleIndicator(
        ctx,
        .{
            .x = closed_box.x + closed_box.width - arrow_size - padding[0],
            .y = closed_box.y + (closed_box.height - arrow_size) / 2,
            .width = arrow_size,
            .height = arrow_size,
        },
        true,
        Color.toWire(ctx.style.text),
    );
    drawTextAtS(
        ctx,
        .{ closed_box.x + closed_box.width + ctx.style.item_spacing[0], closed_box.y + padding[1] },
        label,
        Color.toWire(ctx.style.text),
    );

    // Layout: combo's closed row consumes its row height regardless
    // of open state, just like combo(). The dropdown floats above
    // subsequent widgets without reflowing them.
    const total_w: f32 = closed_box.width + ctx.style.item_spacing[0] + text_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = closed_box.x, .y = closed_box.y, .width = total_w, .height = closed_box.height };
    w.layout.last_item_hovered = hovered_closed;
    markItemPost(ctx, w, false);
    advanceLayout(w, .{ total_w, closed_box.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);

    if (!is_open) {
        return false;
    }

    // -------- Open the dropdown popup ------------------------------------
    // Synthetic key `##combo_<hex>` namespaces dropdown windows
    // away from user-named windows + regular popups. Same key on
    // subsequent frames reuses the same persistent Window so its
    // last-frame size is available for the BG draw below.
    var key_buf: [40]u8 = undefined;
    const key: []const u8 = bufPrint(&key_buf, "##combo_{x}", .{id}) catch return false;

    const dropdown_origin: Vec2 = .{ closed_box.x, closed_box.y + closed_box.height };
    const popup_w: *Window = findOrCreateWindow(ctx, key, .{
        .initial_pos = dropdown_origin,
        .initial_size = .{ box_w, row_h },
    });
    popup_w.pos = dropdown_origin;
    // Width always tracks closed-row width; height grows in endCombo
    // based on submitted-widget cursor advancement.
    popup_w.size[0] = box_w;
    popup_w.last_frame_active = ctx.frame_count;
    popup_w.user_resized = true; // suppress auto-fit grow loop quirks

    const inner_origin: Vec2 = .{
        popup_w.pos[0] + ctx.style.window_padding[0],
        popup_w.pos[1] + ctx.style.window_padding[1],
    };
    resetContentLayout(popup_w, inner_origin);

    ctx.window_stack.append(popup_w) catch return false;
    ctx.current_window = popup_w;

    const saved_dl: ?*DrawList = ctx.current_draw_list;
    if (!ctx.eager_mode) {
        popup_w.draw_list.clear();
        ctx.frame_popups.append(popup_w) catch {};
        ctx.current_draw_list = &popup_w.draw_list;
        // Background panel using LAST frame's size (one-frame lag,
        // tolerable for dropdowns). Both fill + border drawn here so
        // they sit BELOW the items the caller is about to submit.
        drawRectFilled(
            ctx,
            .{ .x = popup_w.pos[0], .y = popup_w.pos[1], .width = popup_w.size[0], .height = popup_w.size[1] },
            Color.toWire(ctx.style.popup_bg),
        );
        drawRect(
            ctx,
            .{ .x = popup_w.pos[0], .y = popup_w.pos[1], .width = popup_w.size[0], .height = popup_w.size[1] },
            Color.toWire(ctx.style.border),
        );
    }

    ctx.combo_scope = .{
        .id = id,
        .dropdown_origin = dropdown_origin,
        .dropdown_width = box_w,
        .saved_dl = saved_dl,
        .saved_window = w,
        .cursor_y = popup_w.layout.cursor_pos[1],
    };
    return true;
}

fn endComboImpl(ctx: *UiContext) void {
    const scope: ComboScope = ctx.combo_scope orelse return;
    const popup_w: *Window = ctx.current_window orelse return;

    // Auto-fit height to actual content for next frame's BG draw.
    // Width stays pinned to closed-row width - combos don't expand
    // horizontally with content.
    const content_h: f32 = popup_w.layout.cursor_max[1] - popup_w.pos[1] + ctx.style.window_padding[1];
    popup_w.size[1] = @max(@as(f32, ctx.style.font_size + 2 * ctx.style.frame_padding[1]), content_h);

    // Click outside both the closed row AND the dropdown closes the
    // combo (next frame). Use the popup rect at its CURRENT size,
    // which after this turn's endCombo reflects this frame's content.
    if (!ctx.eager_mode and ctx.input.mouse_left_clicked) {
        const dropdown_rect: Rectangle = .{
            .x = scope.dropdown_origin[0],
            .y = scope.dropdown_origin[1],
            .width = scope.dropdown_width,
            .height = popup_w.size[1],
        };
        const closed_above: Rectangle = .{
            .x = scope.dropdown_origin[0],
            .y = scope.dropdown_origin[1] - (ctx.style.font_size + 2 * ctx.style.frame_padding[1]),
            .width = scope.dropdown_width,
            .height = ctx.style.font_size + 2 * ctx.style.frame_padding[1],
        };
        const click_in_dropdown: bool = pointInRect(ctx.input.mouse_pos, dropdown_rect);
        const click_on_closed: bool = pointInRect(ctx.input.mouse_pos, closed_above);
        if (!click_in_dropdown and !click_on_closed) {
            ctx.combo_open_id = 0;
        }
    }

    _ = ctx.window_stack.pop();
    ctx.current_window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        null;
    if (!ctx.eager_mode) {
        ctx.current_draw_list = scope.saved_dl;
    }
    ctx.combo_scope = null;
}

/// Pixels of indent per nesting level. ImGui default is 21px.
const indent_amount: f32 = 21;

fn treeNodeExImpl(
    ctx: *UiContext,
    label: []const u8,
    opts: TreeNodeOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    // -------- Resolve open state
    // Priority order:
    //   1. setNextItemOpen overrides everything (one-shot; consumed).
    //   2. Stored state if present.
    //   3. `default_open` flag if this is the first encounter.
    //   4. Closed.
    // For leaf nodes, all of the above is moot - leaves always
    // "return true" (so the caller's children-submission block
    // can run if they have one) and never store toggle state.
    var is_open: bool = blk: {
        if (opts.leaf) {
            break :blk true;
        }
        if (ctx.next_item_open) |forced| {
            ctx.next_item_open = null;
            // Persist so subsequent frames see it.
            ctx.tree_open_state.put(ctx.gpa, id, forced) catch {};
            break :blk forced;
        }
        if (ctx.tree_open_state.get(id)) |stored| {
            break :blk stored;
        }
        // First encounter: apply default_open, persist immediately
        // so toggling from open → closed on first click sticks.
        if (opts.default_open) {
            ctx.tree_open_state.put(ctx.gpa, id, true) catch {};
            break :blk true;
        }
        break :blk false;
    };

    // -------- Layout geometry
    // Vertical: frame_padding (default) OR style.frame_padding when
    // `opts.frame_padding = true`. Either way 2*padding[1] is added
    // to font_size for total height.
    const padding: Vec2 = ctx.style.frame_padding;
    const v_pad: f32 = if (opts.frame_padding) padding[1] else 0;
    const total_h: f32 = ctx.style.font_size + 2 * v_pad;
    const text_size: Vec2 = measureTextS(ctx, label);
    const tri_size: f32 = ctx.style.font_size;

    // Horizontal: triangle/bullet/leaf-gap + 4 gap + label.
    // For leaf with neither bullet nor arrow: still reserve the
    // tri_size so leaf nodes vertically align with siblings.
    const indicator_w: f32 = tri_size;
    const label_x_offset: f32 = indicator_w + 4;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // Width of the highlight / hit rect. `default` covers
    // triangle+gap+label; `avail_width` extends to right edge.
    // Other span variants accepted but treated as `default` /
    // `avail_width` per MVP scope (see TreeNodeSpan doc).
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const natural_w: f32 = label_x_offset + text_size[0];
    const span_w: f32 = switch (opts.span) {
        .default, .label_width => natural_w,
        .avail_width, .full_width, .all_columns, .label_all_columns => inner_right - at[0],
    };

    const rect = Rectangle{ .x = at[0], .y = at[1], .width = span_w, .height = total_h };

    // -------- Hit detect
    // The interact rect depends on `open_trigger`:
    //   - click_anywhere: full `rect`.
    //   - click_arrow: just the indicator's bounding box on the left.
    //   - double_click: full rect but only fires on edge.click2 (TODO).
    const interact_rect: Rectangle = switch (opts.open_trigger) {
        .click_anywhere, .double_click => rect,
        .click_arrow => .{ .x = at[0], .y = at[1], .width = indicator_w, .height = total_h },
    };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, interact_rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    // -------- Click → toggle -------------------------------------------
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    var toggled: bool = false;
    if (ctx.active_id == id and ctx.input.mouse_left_released) {
        if (hovered and !opts.leaf) {
            is_open = !is_open;
            ctx.tree_open_state.put(ctx.gpa, id, is_open) catch {};
            toggled = true;
        }
        ctx.active_id = 0;
    }
    ctx.item_just_toggled = toggled;

    // -------- Render: background
    // `framed`: always draw a frame_bg-color background (looks like
    // a collapsing header).
    // Selected: highlight even when not hovered.
    // Hovered / active: hover/active background.
    if (opts.framed) {
        drawRectFilled(ctx, rect, Color.toWire(ctx.style.frame_bg));
    }
    if (opts.selected) {
        // Use a "header"-ish highlight - reuse button_hovered as the
        // selected-bg slot for now (matches the convention
        // selectable uses). Filed: dedicated `header_*` slots if
        // more widgets want them.
        drawRectFilled(ctx, rect, Color.toWire(ctx.style.button_hovered));
    }
    if (hovered or ctx.active_id == id) {
        const bg: Color = if (ctx.active_id == id)
            ctx.style.frame_bg_active
        else
            ctx.style.frame_bg_hovered;
        drawRectFilled(ctx, rect, Color.toWire(bg));
    }

    // -------- Render: indicator
    // Leaf: no indicator at all (reserves the space for alignment).
    // Bullet: small filled dot.
    // Else: triangle (closed=right, open=down).
    if (!opts.leaf) {
        if (opts.bullet) {
            const bullet_r: f32 = tri_size * 0.18;
            const cx: f32 = at[0] + tri_size * 0.5;
            const cy: f32 = at[1] + total_h * 0.5;
            drawRectFilled(ctx, .{
                .x = cx - bullet_r,
                .y = cy - bullet_r,
                .width = bullet_r * 2,
                .height = bullet_r * 2,
            }, Color.toWire(ctx.style.text));
        } else {
            drawTriangleIndicator(
                ctx,
                .{ .x = at[0], .y = at[1] + (total_h - tri_size) / 2, .width = tri_size, .height = tri_size },
                is_open,
                Color.toWire(ctx.style.text),
            );
        }
    } else if (opts.bullet) {
        // Leaf+bullet: render a bullet at the indicator slot
        // (leaves can still want bullets - matches imgui's
        // "Bullet flag visible regardless of Leaf").
        const bullet_r: f32 = tri_size * 0.18;
        const cx: f32 = at[0] + tri_size * 0.5;
        const cy: f32 = at[1] + total_h * 0.5;
        drawRectFilled(ctx, .{
            .x = cx - bullet_r,
            .y = cy - bullet_r,
            .width = bullet_r * 2,
            .height = bullet_r * 2,
        }, Color.toWire(ctx.style.text));
    }

    // -------- Render: label --------------------------------------------
    drawTextAtS(
        ctx,
        .{ at[0] + label_x_offset, at[1] + v_pad },
        label,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);

    advanceLayout(w, .{ rect.width, total_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);

    // Push indent for children if open. Matches existing
    // behavior; `no_tree_push_on_open` would skip this but isn't
    // wired in MVP (collapsing-header territory).
    if (is_open and !opts.leaf and !opts.no_tree_push_on_open) {
        w.layout.indent_x += indent_amount;
        w.layout.cursor_pos[0] += indent_amount;
    }

    return is_open;
}

/// Options for `setNextWindowPos` / `setNextWindowSize`. Mirrors
/// ImGui's `ImGuiCond` enum collapsed to the only two we actually
/// support: always (default) and once (apply only on first creation).
pub const SetNextWindowOpts = struct {
    /// If true, the override applies only the first time this window
    /// is created. After that the user can drag/resize freely and
    /// subsequent `setNextWindow*` calls with `once = true` are
    /// ignored. Default false ⇒ apply every frame (ImGui's
    /// `ImGuiCond_Always`).
    once: bool = false,
};

/// Walk to the root of the parent chain. Returns `start` if it
/// has no parent. Bounded at 32 hops same as `isWindowAncestor`.
fn rootWindowOf(ctx: *UiContext, start: *Window) *Window {
    var current: *Window = start;
    var hops: u8 = 0;
    while (hops < 32) : (hops += 1) {
        const parent_id: Id = current.parent_id orelse return current;
        const parent: *Window = ctx.windows.get(parent_id) orelse return current;
        current = parent;
    }
    return current;
}

/// Walk up `descendant`'s `parent_id` chain looking for `ancestor`.
/// Returns true if descendant IS ancestor, OR if ancestor sits
/// anywhere on the chain. Used by `isWindowHovered` /
/// `isWindowFocused` for the `child_windows` flag - "is the
/// hovered/focused window me or a descendant of me?"
/// Bounded at 32 hops to defend against accidental cycles (which
/// shouldn't be possible - parent_id is set once per frame from
/// the window_stack - but cheap belt-and-suspenders).
fn isWindowAncestor(
    ctx: *UiContext,
    ancestor: *const Window,
    descendant_id: Id,
) bool {
    var current_id: Id = descendant_id;
    var hops: u8 = 0;
    while (hops < 32) : (hops += 1) {
        if (current_id == ancestor.id) {
            return true;
        }
        const cur: *const Window = ctx.windows.get(current_id) orelse return false;
        const parent_id: Id = cur.parent_id orelse return false;
        current_id = parent_id;
    }
    return false;
}

/// Read effective ItemFlags - top of `item_flags_stack`, or
/// all-defaults if empty.
fn currentItemFlagsImpl(ctx: *UiContext) ItemFlags {
    const len: usize = ctx.item_flags_stack.len;
    if (len == 0) {
        return .{};
    }
    return ctx.item_flags_stack.items[len - 1];
}

fn closePopupScope(ctx: *UiContext) void {
    const popup_w: *Window = ctx.current_window orelse return;

    // Auto-fit size to content used this frame (popups always
    // auto-fit; user can't manually resize them).
    const content_w: f32 = popup_w.layout.cursor_max[0] - popup_w.pos[0] + ctx.style.window_padding[0];
    const content_h: f32 = popup_w.layout.cursor_max[1] - popup_w.pos[1] + ctx.style.window_padding[1];
    if (content_w > popup_w.size[0]) {
        popup_w.size[0] = content_w;
    }
    if (content_h > popup_w.size[1]) {
        popup_w.size[1] = content_h;
    }

    // Pop content clip rect.
    if (!ctx.eager_mode) {
        popup_w.draw_list.popClipRect(ctx.frame_arena.allocator());
    }

    // Pop window + popup stack.
    _ = ctx.window_stack.pop();
    _ = ctx.popup_stack.pop();
    ctx.current_window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        null;
    if (!ctx.eager_mode) {
        if (ctx.current_window) |outer| {
            ctx.current_draw_list = &outer.draw_list;
        } else {
            ctx.current_draw_list = null;
        }
    }
}

fn openMainMenuBar(ctx: *UiContext) bool {
    // Allocate the synthetic bar window lazily. Same persistent
    // storage as regular windows, keyed by a `##main_menu_bar`
    // synthetic title (won't collide with any user-named window).
    if (ctx.main_menu_bar_win == null) {
        const bar: *Window = findOrCreateWindow(
            ctx,
            "##main_menu_bar",
            .{
                .initial_pos = .{ 0, 0 },
                .initial_size = .{ 0, ctx.style.menu_bar_height },
            },
        );
        bar.user_resized = true; // skip auto-fit grow
        ctx.main_menu_bar_win = bar;
    }
    const bar: *Window = ctx.main_menu_bar_win.?;
    const cw: f32 = float(ctx.canvas_w);
    bar.pos = .{ 0, 0 };
    bar.size = .{ cw, ctx.style.menu_bar_height };
    bar.last_frame_active = ctx.frame_count;
    bar.layout.cursor_pos = .{ 8, 0 };
    bar.layout.cursor_max = bar.layout.cursor_pos;
    bar.layout.line_height = ctx.style.menu_bar_height;
    bar.layout.indent_x = 0;

    // Push onto window stack + record menu-bar mode.
    ctx.window_stack.append(bar) catch {
        return false;
    };
    ctx.current_window = bar;
    ctx.in_menu_bar = true;
    ctx.active_menu_bar_win = bar;
    ctx.menu_bar_cursor_x = 8;
    ctx.menu_bar_bottom_y = ctx.style.menu_bar_height;

    // Append to per-frame popup list so the bar replays AFTER
    // regular windows (above all content) but BEFORE foreground
    // (tooltips still float on top). Popups submitted from inside
    // beginMenu will append later; bar appears first → menus on top.
    if (!ctx.eager_mode) {
        bar.draw_list.clear();
        ctx.frame_popups.append(bar) catch {};
        ctx.current_draw_list = &bar.draw_list;
    }

    // Render bar background.
    drawRectFilled(
        ctx,
        .{ .x = 0, .y = 0, .width = cw, .height = ctx.style.menu_bar_height },
        Color.toWire(ctx.style.menu_bar_bg),
    );
    return true;
}

fn closeMainMenuBar(ctx: *UiContext) void {
    if (!ctx.in_menu_bar) {
        return;
    }
    ctx.in_menu_bar = false;
    ctx.active_menu_bar_win = null;
    _ = ctx.window_stack.pop();
    ctx.current_window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        null;
    if (!ctx.eager_mode) {
        if (ctx.current_window) |outer| {
            ctx.current_draw_list = &outer.draw_list;
        } else {
            ctx.current_draw_list = null;
        }
    }
}

/// Per-window menu bar. Unlike `openMainMenuBar`, this one doesn't
/// spin up a synthetic bar window - the bar is drawn into the
/// CURRENT window's pixels at a horizontal strip just below the
/// title bar. Menu items + their dropdowns reuse the same paths
/// as the main bar via `active_menu_bar_win`.
/// Position: the bar occupies `[win.pos.y + title_bar_height,
/// win.pos.y + title_bar_height + menu_bar_height)`. The window's
/// content cursor is bumped down by `menu_bar_height` so user widgets
/// don't draw on top. No interaction with `window_padding.y` - the
/// bar sits flush with the title bar; padding kicks in BELOW the
/// bar before content.
fn openWindowMenuBar(ctx: *UiContext) bool {
    if (ctx.in_menu_bar) {
        return false;
    } // already in a bar - caller bug
    const win: *Window = ctx.current_window orelse return false;
    // P5.5a: require `WindowFlags.menu_bar` on the parent
    // window. Matches imgui's `ImGuiWindowFlags_MenuBar` contract —
    // callers must declare intent at window creation time. This
    // lets a future layout pass reserve the menu-bar's height
    // *before* content is submitted (today we shift cursor down
    // mid-frame, which is fine for single-frame menus but blocks
    // proper layout-driven content rect computation).
    if (!win.flags.menu_bar) {
        return false;
    }
    const bar_h: f32 = ctx.style.menu_bar_height;
    const bar_y: f32 = win.pos[1] + ctx.style.title_bar_height;
    // Paint bar background using current_draw_list (the parent
    // window's list). Same color as the main menu bar so the look
    // is consistent across the two bar paths.
    drawRectFilled(
        ctx,
        .{ .x = win.pos[0], .y = bar_y, .width = win.size[0], .height = bar_h },
        Color.toWire(ctx.style.menu_bar_bg),
    );
    // Activate bar-mode. `menu_bar_cursor_x` / `menu_bar_bottom_y`
    // live in canvas coords so `openMenu` can position the button
    // and anchor its popup without caring which bar path opened us.
    ctx.in_menu_bar = true;
    ctx.active_menu_bar_win = win;
    ctx.menu_bar_cursor_x = win.pos[0] + 8;
    ctx.menu_bar_bottom_y = bar_y + bar_h;
    // Push window content down past the bar so subsequent widgets
    // in the user's window body land below it.
    win.layout.cursor_pos[1] += bar_h;
    win.layout.cursor_max[1] = @max(win.layout.cursor_max[1], win.layout.cursor_pos[1]);
    return true;
}

fn closeWindowMenuBar(ctx: *UiContext) void {
    if (!ctx.in_menu_bar) {
        return;
    }
    ctx.in_menu_bar = false;
    ctx.active_menu_bar_win = null;
}

fn openMenu(
    ctx: *UiContext,
    label: []const u8,
    enabled: bool,
) bool {
    if (!ctx.in_menu_bar) {
        // Submenu inside an open menu - also handled like a popup
        // anchored to the parent menu's row. Out of scope for
        // 's first cut; left as no-op for now.
        return false;
    }
    const bar: *Window = ctx.active_menu_bar_win orelse return false;
    const id: Id = hashStr(bar.id, label);
    const padding_x: f32 = 12;
    const text_size: Vec2 = measureTextS(ctx, label);
    const button_w: f32 = text_size[0] + 2 * padding_x;
    const button_h: f32 = ctx.style.menu_bar_height;
    const button_rect: Rectangle = .{
        .x = ctx.menu_bar_cursor_x,
        // The bar's top in canvas coords. menu_bar_bottom_y = bar_top + bar_h
        // for BOTH paths (main bar: bottom=height so this is 0; per-window bar:
        // win.pos.y + title_h). Was hardcoded 0 — correct only for the main bar,
        // so per-window menu labels drew at the canvas top instead of in the
        // window's strip (and hit-tested there too). This fixes both.
        .y = ctx.menu_bar_bottom_y - button_h,
        .width = button_w,
        .height = button_h,
    };
    const hovered: bool = enabled and pointInRect(ctx.input.mouse_pos, button_rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    const popup_id: Id = hashStr(bar.id, label); // the menu's popup uses this key
    const anchor: Vec2 = .{ button_rect.x, ctx.menu_bar_bottom_y };

    // Single-open + hover-tracking (imgui menu-bar behaviour). `bar_open_menu` is
    // the ONE menu the bar treats as open (only one is ever open at a time) — the
    // intent — while `popup_open` is the live popup set (which click-outside
    // dismissal also edits).
    // 0) If the designated menu was dismissed externally (clicked outside), drop
    //    the intent so a later hover doesn't spuriously reopen it.
    if (ctx.bar_open_menu) |open_id| {
        if (!ctx.popup_open.contains(open_id)) {
            ctx.bar_open_menu = null;
        }
    }
    // 1) Tracking: once a menu is open, hovering a DIFFERENT label switches to it.
    if (ctx.bar_open_menu != null and hovered and ctx.bar_open_menu.? != popup_id) {
        ctx.popup_open.put(ctx.gpa, popup_id, .{ .anchor = anchor, .opened_at_frame = ctx.frame_count }) catch {};
        ctx.bar_open_menu = popup_id;
    }
    // 2) Click toggles this menu (open when closed, close when already open).
    if (hovered and ctx.input.mouse_left_clicked) {
        if (ctx.bar_open_menu == popup_id) {
            _ = ctx.popup_open.remove(popup_id);
            ctx.bar_open_menu = null;
        } else {
            ctx.popup_open.put(ctx.gpa, popup_id, .{ .anchor = anchor, .opened_at_frame = ctx.frame_count }) catch {};
            ctx.bar_open_menu = popup_id;
        }
    }
    // 3) Single-open: any menu that's open but NOT the designated one closes.
    if (ctx.bar_open_menu != null and ctx.bar_open_menu.? != popup_id and ctx.popup_open.contains(popup_id)) {
        _ = ctx.popup_open.remove(popup_id);
    }
    const is_open: bool = ctx.bar_open_menu == popup_id and ctx.popup_open.contains(popup_id);
    if (is_open) {
        // Remember the button rect so click-outside dismissal can tell a toggle
        // click (on the button) from a real outside click (it runs next frame).
        ctx.bar_open_menu_btn = button_rect;
    }

    // Render button background - accent on hover OR open state.
    if (hovered or is_open) {
        drawRectFilled(ctx, button_rect, Color.toWire(ctx.style.menu_hovered));
    }
    // Label, vertically centered.
    drawTextAtS(
        ctx,
        .{ button_rect.x + padding_x, button_rect.y + (button_h - ctx.style.font_size) / 2 },
        label,
        Color.toWire(ctx.style.text),
    );

    // Advance bar cursor.
    ctx.menu_bar_cursor_x += button_w;

    if (!is_open) {
        return false;
    }
    // Enter the menu's popup scope. Anchor was set at click-time
    // to the button's bottom-left. The popup's body submission
    // begins now; user calls menuItem(...) inside.
    return openPopupScope(ctx, label);
}

pub const MenuItemOpts = struct {
    /// Optional right-aligned shortcut hint (e.g. "Ctrl+S").
    /// Display-only - caller still wires the actual key handler.
    shortcut: []const u8 = "",
    /// Show a checkmark on the left. Toggle-style menu items
    /// pass their boolean's current value here.
    selected: bool = false,
    /// Greyed-out + non-interactive when false.
    enabled: bool = true,
};

fn menuItemImpl(
    ctx: *UiContext,
    label: []const u8,
    opts: MenuItemOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);
    const padding: Vec2 = ctx.style.frame_padding;
    const row_h: f32 = ctx.style.font_size + 2 * padding[1];

    // Full-row layout - extend to the parent popup's right edge.
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    // Min row width: label + shortcut + 2 paddings + spacing.
    const label_size: Vec2 = measureTextS(ctx, label);
    const shortcut_size: Vec2 = if (opts.shortcut.len > 0)
        measureTextS(ctx, opts.shortcut)
    else
        .{ 0, 0 };
    const min_w: f32 = label_size[0] + shortcut_size[0] + 4 * padding[0] + ctx.style.item_spacing[0];
    const row_w: f32 = @max(min_w, inner_right - at[0]);
    const rect: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = row_w,
        .height = row_h,
    };

    const ptr_in_rect: bool = pointInRect(ctx.input.mouse_pos, rect);
    const hovered: bool = opts.enabled and ptr_in_rect;
    if (hovered) {
        ctx.hovered_id = id;
    }
    var clicked: bool = false;
    if (hovered and ctx.input.mouse_left_clicked) {
        clicked = true;
        // Auto-close the menu on item click - matches OS-style
        // menu UX. Closes the topmost popup (the menu itself).
        if (ctx.popup_stack.top()) |top_id| {
            _ = ctx.popup_open.remove(top_id);
        }
    }

    // Background - hover highlight; greyed if disabled.
    if (hovered) {
        drawRectFilled(ctx, rect, Color.toWire(ctx.style.menu_hovered));
    }
    // Optional checkmark (on the left, before the label).
    const checkmark_size: f32 = 8;
    const left_pad: f32 = padding[0] * 3; // room for a checkmark
    if (opts.selected) {
        drawRectFilled(
            ctx,
            .{
                .x = rect.x + padding[0],
                .y = rect.y + (row_h - checkmark_size) / 2,
                .width = checkmark_size,
                .height = checkmark_size,
            },
            Color.toWire(ctx.style.text),
        );
    }
    const text_col: Color = if (opts.enabled) ctx.style.text else ctx.style.text_disabled;
    drawTextAtS(
        ctx,
        .{ rect.x + left_pad, rect.y + padding[1] },
        label,
        Color.toWire(text_col),
    );
    // Right-aligned shortcut text.
    if (opts.shortcut.len > 0) {
        drawTextAtS(
            ctx,
            .{ rect.x + rect.width - shortcut_size[0] - padding[0], rect.y + padding[1] },
            opts.shortcut,
            Color.toWire(ctx.style.text_disabled),
        );
    }

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);
    advanceLayout(
        w,
        .{ rect.width, rect.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return clicked;
}

/// Pop the persisted selected-tab label for a `(window_name, str_id)`
/// pair. Returns null when nothing was persisted for this bar.
/// Returns an empty string when the persistence layer recorded an
/// entry but no tab was selected - caller can treat empty as
/// "no selection" (matches `state.selected_id = 0` semantics).
/// Returned slice is owned by the caller; free with `ctx.gpa.free`.
pub fn takeTabBarSelection(
    ctx: *UiContext,
    window_name: []const u8,
    str_id: []const u8,
) ?[]const u8 {
    // Build a transient compound key to look up; we don't want
    // to allocate just for the lookup, so use a small stack
    // buffer with a fallback to gpa for unusually long names.
    var stack_buf: [256]u8 = undefined;
    const needed: usize = window_name.len + 1 + str_id.len;
    var search_key: []const u8 = undefined;
    var search_key_owned: ?[]u8 = null;
    defer if (search_key_owned) |b| ctx.gpa.free(b);
    if (needed <= stack_buf.len) {
        @memcpy(stack_buf[0..window_name.len], window_name);
        stack_buf[window_name.len] = 0;
        @memcpy(stack_buf[window_name.len + 1 .. needed], str_id);
        search_key = stack_buf[0..needed];
    } else {
        const owned = ctx.gpa.alloc(u8, needed) catch return null;
        @memcpy(owned[0..window_name.len], window_name);
        owned[window_name.len] = 0;
        @memcpy(owned[window_name.len + 1 ..], str_id);
        search_key_owned = owned;
        search_key = owned;
    }

    const entry: std.StringHashMapUnmanaged([]const u8).KV =
        ctx.pending_tab_bar_selections.fetchRemove(search_key) orelse return null;
    // Free the entry's key (we allocated it in `apply`); return
    // the value slice to the caller.
    ctx.gpa.free(@constCast(entry.key));
    return entry.value;
}

fn openTabBar(
    ctx: *UiContext,
    str_id: []const u8,
    flags: TabBarFlags,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, str_id);
    // Resolve any pending sameLine; we want the bar at the current row.
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const bar_h: f32 = ctx.style.font_size + 2 * ctx.style.frame_padding[1];
    const right_x: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const bar_rect: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = @max(0, right_x - at[0]),
        .height = bar_h,
    };

    // Find or create persistent state.
    if (!ctx.tab_bar_state.contains(id)) {
        ctx.tab_bar_state.put(ctx.gpa, id, .{}) catch return false;
        // on first-creation, populate the
        // bar's name fields so persistence (and any other consumer
        // that needs a stable human identifier) can round-trip the
        // bar without holding onto the user's borrowed str_id.
        // Done here rather than in TabBarState's init so we don't
        // need to plumb name through `getPtr`/`put` calls.
        const new_state: *TabBarState = ctx.tab_bar_state.getPtr(id).?;
        const wname: []const u8 = w.name();
        const wname_n: usize = @min(wname.len, new_state.parent_window_name_buf.len);
        @memcpy(new_state.parent_window_name_buf[0..wname_n], wname[0..wname_n]);
        new_state.parent_window_name_len = wname_n;
        const sid_n: usize = @min(str_id.len, new_state.str_id_buf.len);
        @memcpy(new_state.str_id_buf[0..sid_n], str_id[0..sid_n]);
        new_state.str_id_len = sid_n;

        // Persistence restore: if the persistence layer
        // staged a selected-tab label for this (window, str_id)
        // pair on startup, hash it now to derive the would-be tab
        // Id and stash on `selected_id`. beginTabItem's equality
        // check then routes the matching tab as active. If no tab
        // submitted this frame matches the persisted id, the
        // fallback path in closeTabBar resets `selected_id` to 0
        // so first-tab-auto-select kicks in next frame.
        if (takeTabBarSelection(ctx, wname, str_id)) |label| {
            defer ctx.gpa.free(@constCast(label));
            new_state.selected_id = hashStr(id, label);
        }
    }
    const state: *TabBarState = ctx.tab_bar_state.getPtr(id).?;
    state.last_frame_active = ctx.frame_count;

    ctx.tab_bar_stack.append(.{
        .id = id,
        .rect = bar_rect,
        .flags = flags,
        .leading_cursor_x = bar_rect.x,
        .middle_cursor_x = bar_rect.x, // leading section grows this rightward
        .trailing_cursor_x = bar_rect.x + bar_rect.width, // trailing grows leftward
        .selection_changed_this_frame = false,
        .pushed_tab_id_this_item = false,
    }) catch return false;

    // ---- Underline separator running across the bar's full width ----
    //

    // Drawn HERE (before any tabs are submitted) so per-tab background
    // rects can overdraw it. Active tabs render at full bar height
    // and so cover the separator at their position — that's the visual
    // "lift" effect (the active tab merges with the content below).
    // Inactive tabs render at `bar_height - 1` so the separator
    // remains visible at their bottom edge.
    //

    // Previously this was drawn in closeTabBar, AFTER the tabs. That
    // meant the separator overdrew every tab including the active
    // one, and a separately-drawn 1px lift slice was needed to
    // restore the active-tab merge. Drawing separator-first lets us
    // get the lift effect for free via the tab bg's own geometry.
    // imgui takes a third route — drawing the tab shape with rounded
    // top corners that geometrically end above the separator
    // (`imgui_widgets.cpp:10991`, `TabItemBackground`). Our flat-rect
    // approach is simpler at the cost of one cosmetic detail (no
    // rounded corners).
    drawRectFilled(
        ctx,
        .{
            .x = bar_rect.x,
            .y = bar_rect.y + bar_rect.height - 1,
            .width = bar_rect.width,
            .height = 1,
        },
        Color.toWire(ctx.style.tab_separator),
    );

    // Advance the parent cursor past the bar's full row NOW. Doing
    // this in `closeTabBar` instead - as we used to - meant the
    // active tab's content was submitted at the same y as
    // the tab strip itself, overlapping the strip. Advancing here
    // lays out the strip at `at`, then leaves the cursor on the row
    // below it for the active tab's body. Visible in the
    // ui_tabbar_bug-turn317.png screenshot when the bug was present.
    advanceLayout(
        w,
        .{ bar_rect.width, bar_rect.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );

    return true;
}

fn closeTabBar(ctx: *UiContext) void {
    const frame: TabBarFrame = ctx.tab_bar_stack.pop() orelse return;
    const state: *TabBarState = ctx.tab_bar_state.getPtr(frame.id) orelse return;

    // ---- GC tabs whose caller stopped submitting them this frame ----
    // Walk backwards so removals don't shift indices we haven't visited.
    var i: usize = state.tabs.items.len;
    while (i > 0) {
        i -= 1;
        if (state.tabs.items[i].last_frame_active < ctx.frame_count) {
            // If the GC'd tab was selected, clear so endTabBar's "first
            // tab takes over" code path runs next frame.
            if (state.tabs.items[i].id == state.selected_id) {
                state.selected_id = 0;
            }
            _ = state.tabs.orderedRemove(i);
        }
    }

    // ---- Finalize close-clicks (set caller's open_ptr.* = false) -----
    // Done here so callers see the change on the NEXT frame's begin
    // (we don't want want_close to also affect the rendering of THIS
    // frame - clicking the X this frame still keeps the tab visible
    // until the user submits next frame; matches imgui semantics).
    for (state.tabs.items) |*t| {
        if (t.want_close) {
            if (t.open_ptr_cache) |p| {
                p.* = false;
            }
            t.want_close = false;
        }
    }

    // persistence: if `selected_id` was restored from a
    // persisted payload at openTabBar time but no submitted tab
    // matches that id (the caller may have removed the tab between
    // sessions), reset to 0 so first-tab-auto-select takes over
    // next frame. Without this, the bar shows no active tab
    // visible only on the first post-reload frame.
    if (state.selected_id != 0 and state.findTab(state.selected_id) == null) {
        state.selected_id = 0;
    }

    // Remember bar's bottom edge for external anchors.
    // (The bar's underline separator was drawn in openTabBar — see
    // the comment block there for why it's first, not last.)
    state.bottom_y = frame.rect.y + frame.rect.height;

    // (Parent cursor was advanced in `openTabBar` so active-tab
    // content lays out below the strip rather than overlapping it.
    // bug fix - see the comment block there.)
}

fn closeTabItem(ctx: *UiContext) void {
    if (ctx.tab_bar_stack.len == 0) {
        return;
    }
    const frame_idx: usize = ctx.tab_bar_stack.len - 1;
    const frame: *TabBarFrame = &ctx.tab_bar_stack.items[frame_idx];
    if (frame.pushed_tab_id_this_item) {
        _ = ctx.id_stack.pop();
        frame.pushed_tab_id_this_item = false;
    }
}

fn closeListBox(ctx: *UiContext) void {
    const list_box: *Window = ctx.current_window orelse return;
    if (!list_box.flags.is_child) {
        return;
    } // mismatched begin/end - bail safely

    // Capture the list box's outer rect before we pop, so we can
    // advance the parent's cursor past it AND hit-test the wheel.
    const box_rect: Rectangle = .{
        .x = list_box.pos[0],
        .y = list_box.pos[1],
        .width = list_box.size[0],
        .height = list_box.size[1],
    };

    // ---- Scroll bookkeeping
    // Same shape as `endChildImpl` - see notes there.
    const inner_origin_y: f32 = list_box.layout.origin[1];
    const content_h_natural: f32 = list_box.layout.cursor_max[1] + list_box.scroll_y - inner_origin_y;
    const viewport_h: f32 = @max(0, list_box.layout.work_rect_max[1] - inner_origin_y);
    list_box.scroll_max_y = @max(0, content_h_natural - viewport_h);

    if (!ctx.mouse_wheel_consumed and ctx.input.mouse_wheel_y != 0 and pointInRect(ctx.input.mouse_pos, box_rect)) {
        const wheel_speed: f32 = 5 * ctx.style.font_size;
        list_box.scroll_y -= ctx.input.mouse_wheel_y * wheel_speed;
        ctx.mouse_wheel_consumed = true;
    }
    list_box.scroll_y = @max(0, @min(list_box.scroll_y, list_box.scroll_max_y));

    // Pop content clip, then render scrollbar (so the scrollbar
    // isn't clipped out alongside the content).
    if (!ctx.eager_mode) {
        if (ctx.current_draw_list) |dl| {
            dl.popClipRect(ctx.frame_arena.allocator());
        }
    }
    if (list_box.scroll_max_y > 0) {
        renderScrollbar(ctx, list_box);
    }

    _ = ctx.id_stack.pop();
    _ = ctx.window_stack.pop();
    const parent: *Window = if (ctx.window_stack.len > 0)
        ctx.window_stack.items[ctx.window_stack.len - 1]
    else
        return; // malformed nesting
    ctx.current_window = parent;

    // The list box becomes the parent's "last item" so `isItemHovered`
    // after `endListBox` queries the box's outer rect. Set the id
    // to the list_box_id (NOT zero like beginChild's child rect)
    // because the list box IS an interactive item - focus state
    // hangs off it.
    parent.layout.last_item_id = list_box.id;
    parent.layout.last_item_rect = box_rect;
    parent.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, box_rect);

    // Advance the parent's cursor past the box's outer size.
    advanceLayout(parent, .{ box_rect.width, box_rect.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

fn listBoxImpl(
    ctx: *UiContext,
    label: []const u8,
    current: *i32,
    items: []const []const u8,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    if (!ui.beginListBox(label, .{ 0, 0 })) {
        return false;
    }
    defer ui.endListBox();

    var changed: bool = false;
    var i: i32 = 0;
    while (i < @as(i32, @intCast(items.len))) : (i += 1) {
        const idx: usize = @intCast(i);
        const sel: bool = current.* == i;
        if (ui.selectable(items[idx], sel, .{})) {
            if (current.* != i) {
                current.* = i;
                changed = true;
            }
        }
    }
    return changed;
}

fn progressBarImpl(
    ctx: *UiContext,
    fraction: f32,
    size: Vec2,
    overlay: []const u8,
) void {
    const w: *Window = ctx.current_window orelse return;
    const padding: Vec2 = ctx.style.frame_padding;
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // Resolve auto sizes. Width: full content row. Height: 1 text row + padding.
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const default_w: f32 = @max(60, inner_right - at[0]);
    const default_h: f32 = ctx.style.font_size + 2 * padding[1];
    const bar_w: f32 = if (size[0] > 0) size[0] else default_w;
    const bar_h: f32 = if (size[1] > 0) size[1] else default_h;
    const bar: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = bar_w,
        .height = bar_h,
    };

    // Clamp fraction to [0, 1].
    const f_clamped: f32 = clamp(fraction, 0, 1);
    const fill_w: f32 = bar_w * f_clamped;
    const fill: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = fill_w,
        .height = bar_h,
    };

    // Background, then fill.
    drawRectFilled(ctx, bar, Color.toWire(ctx.style.frame_bg));
    drawRectFilled(ctx, fill, Color.toWire(ctx.style.button));

    // Optional centred overlay caption.
    if (overlay.len > 0) {
        const overlay_size: Vec2 = measureTextS(ctx, overlay);
        const overlay_pos: Vec2 = .{ at[0] + (bar_w - overlay_size[0]) / 2, at[1] + (bar_h - ctx.style.font_size) / 2 };
        drawTextAtS(ctx, overlay_pos, overlay, Color.toWire(ctx.style.text));
    }

    // Item-state record + cursor advance.
    w.layout.last_item_id = 0;
    w.layout.last_item_rect = bar;
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, bar);
    markItemPost(ctx, w, false);
    advanceLayout(
        w,
        .{ bar_w, bar_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
}

fn vSliderScalar(
    ctx: *UiContext,
    label: []const u8,
    size: Vec2,
    value: anytype,
    comptime T: type,
    opts: SliderOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);
    const padding: Vec2 = ctx.style.frame_padding;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const default_w: f32 = ctx.style.font_size + 2 * padding[0];
    const default_h: f32 = ctx.style.font_size * 6;
    const bar_w: f32 = if (size[0] > 0) size[0] else default_w;
    const bar_h: f32 = if (size[1] > 0) size[1] else default_h;
    const bar: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = bar_w,
        .height = bar_h,
    };

    const ptr_in_bar: bool = pointInRect(ctx.input.mouse_pos, bar);
    if (ptr_in_bar) {
        ctx.hovered_id = id;
    }
    var changed: bool = false;

    // Click-to-grab - capture active_id while dragging.
    if (ptr_in_bar and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
    }
    const is_active: bool = ctx.active_id == id;
    if (is_active and !ctx.input.mouse_left_down) {
        ctx.active_id = 0;
    }

    // While active, drive the value from mouse_y position within bar.
    if (is_active) {
        const t: f32 = clamp((ctx.input.mouse_pos[1] - bar.y) / bar.height, 0, 1);
        // ImGui's vertical-slider convention: top = max, bottom = min.
        const t_inverted: f32 = 1.0 - t;
        const new_v_f64: f64 = opts.min + (opts.max - opts.min) * @as(f64, t_inverted);
        const new_v: T = scalarFromF64(T, new_v_f64);
        if (new_v != value.*) {
            value.* = new_v;
            changed = true;
        }
    }

    // Render bar background + filled portion.
    drawRectFilled(ctx, bar, Color.toWire(ctx.style.frame_bg));
    const cur_f64: f64 = scalarToF64(T, value.*);
    const range: f64 = opts.max - opts.min;
    const t_cur: f64 = if (range == 0) 0 else (cur_f64 - opts.min) / range;
    const fill_h: f32 = bar_h * @as(f32, @floatCast(t_cur));
    const grab_col: Color = if (is_active)
        ctx.style.button_active
    else if (ptr_in_bar)
        ctx.style.button_hovered
    else
        ctx.style.button;
    // Fill grows from the bottom up (matches "level meter" convention).
    drawRectFilled(
        ctx,
        .{
            .x = bar.x,
            .y = bar.y + bar.height - fill_h,
            .width = bar.width,
            .height = fill_h,
        },
        Color.toWire(grab_col),
    );

    // Value caption ABOVE the bar.
    var val_buf: [64]u8 = undefined;
    const val_str: []const u8 = formatScalar(&val_buf, T, value.*, opts.fmt);
    const val_size: Vec2 = measureTextS(ctx, val_str);
    const caption_gap_px: f32 = 2; // gap between caption baseline and bar top
    drawTextAtS(
        ctx,
        .{ bar.x + (bar.width - val_size[0]) / 2, bar.y - val_size[1] - caption_gap_px },
        val_str,
        Color.toWire(ctx.style.text),
    );

    // Item state + advance.
    w.layout.last_item_id = id;
    w.layout.last_item_rect = bar;
    w.layout.last_item_hovered = ptr_in_bar;
    markItemPost(ctx, w, changed);
    advanceLayout(
        w,
        .{ bar_w, bar_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return changed;
}

fn vSliderDispatch(
    ctx: *UiContext,
    label: []const u8,
    size: Vec2,
    value: anytype,
    opts: SliderOpts,
) bool {
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.vSlider: pass a single-item pointer (e.g., &my_value), got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    return switch (@typeInfo(Child)) {
        .float => vSliderScalar(ctx, label, size, value, Child, opts),
        .int => vSliderScalar(ctx, label, size, value, Child, opts),
        else => @compileError("ui.vSlider: child type must be float or int, got " ++ @typeName(Child)),
    };
}

pub const InputScalarOpts = struct {
    /// std.fmt-compatible format used to render the current value
    /// before the user starts editing. Floats default to 3
    /// decimal places; ints to plain `{d}`.
    fmt: []const u8 = "{d:.3}",
    /// Override the input box width (zero = auto-fit row).
    width: f32 = 0,
};

/// Parse a slice into T. Returns null on parse failure (caller
/// keeps the previous value). Trims surrounding whitespace.
fn parseScalar(comptime T: type, raw: []const u8) ?T {
    const trimmed: []const u8 = std.mem.trim(u8, raw, " \t");
    if (trimmed.len == 0) {
        return null;
    }
    return switch (@typeInfo(T)) {
        .float => std.fmt.parseFloat(T, trimmed) catch null,
        .int => std.fmt.parseInt(T, trimmed, 10) catch null,
        else => null,
    };
}

/// Render an input box at an explicit rect + id. The caller owns
/// layout - the box is drawn, the state machine runs, the value is
/// edited. Caller is responsible for drawing any label and for
/// advancing the layout afterward. Used by both single-input and
/// multi-component-input paths.
fn inputScalarInRect(
    ctx: *UiContext,
    w: *Window,
    id: Id,
    box: Rectangle,
    value_ptr: anytype,
    comptime T: type,
    opts: InputScalarOpts,
) bool {
    const padding: Vec2 = ctx.style.frame_padding;
    const hovered: bool = pointInRect(ctx.input.mouse_pos, box);
    if (hovered) {
        ctx.hovered_id = id;
    }

    // Click to focus. On focus-gain, seed the edit buffer with the
    // current value formatted via opts.fmt - so the user sees what's
    // there and can edit in place. This block runs BEFORE the
    // `is_focused` capture so a click + chars_typed on the SAME
    // frame both register correctly: focus first, then process the
    // chars using the just-set active_id.
    const click_to_focus: bool = hovered and ctx.input.mouse_left_clicked;
    if (click_to_focus) {
        ctx.active_id = id;
        var seed_buf: [64]u8 = undefined;
        const seeded: []const u8 = formatScalar(&seed_buf, T, value_ptr.*, opts.fmt);
        ctx.scalar_edit_buf = @splat(0);
        @memcpy(ctx.scalar_edit_buf[0..seeded.len], seeded);
        ctx.scalar_edit_len = seeded.len;
        ctx.active_id_just_activated = true;
    }
    const is_focused: bool = ctx.active_id == id;

    // Click outside box deactivates AND triggers commit.
    var commit_now: bool = false;
    const click_outside: bool = ctx.input.mouse_left_clicked and !hovered;
    if (is_focused and click_outside) {
        commit_now = true;
        ctx.active_id = 0;
    }
    // Enter commits. Escape cancels (just clears focus, no write).
    if (is_focused and ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame) {
        commit_now = true;
        ctx.active_id = 0;
    }
    if (is_focused and ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame) {
        ctx.active_id = 0;
        ctx.scalar_edit_len = 0;
    }
    // Backspace.
    if (is_focused and ctxKeyPressedOrRepeat(ctx, .backspace)) {
        if (ctx.scalar_edit_len > 0) {
            ctx.scalar_edit_len -= 1;
            ctx.scalar_edit_buf[ctx.scalar_edit_len] = 0;
        }
    }

    // While focused, route typed characters into the edit buffer.
    var changed: bool = false;
    if (is_focused) {
        var i: usize = 0;
        while (i < ctx.input.chars_typed_count) : (i += 1) {
            const ch: u32 = ctx.input.chars_typed[i];
            // Numeric chars + sign + dot only (rough filter - full
            // parse happens at commit).
            const is_digit: bool = ch >= '0' and ch <= '9';
            const is_punct: bool = ch == '.' or ch == '-' or ch == '+' or ch == 'e' or ch == 'E';
            const is_acceptable: bool = is_digit or is_punct;
            const has_room: bool = ctx.scalar_edit_len < ctx.scalar_edit_buf.len;
            if (is_acceptable and has_room) {
                ctx.scalar_edit_buf[ctx.scalar_edit_len] = @intCast(ch);
                ctx.scalar_edit_len += 1;
            }
        }
    }

    // On commit, parse the buffer and write to value_ptr if valid.
    if (commit_now) {
        const text_slice: []const u8 = ctx.scalar_edit_buf[0..ctx.scalar_edit_len];
        const parsed: ?T = parseScalar(T, text_slice);
        if (parsed) |p| {
            if (p != value_ptr.*) {
                value_ptr.* = p;
                changed = true;
            }
        }
        ctx.scalar_edit_len = 0;
    }

    // Render frame.
    const bg_col: Color = if (is_focused)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, box, Color.toWire(bg_col));
    drawRect(ctx, box, Color.toWire(ctx.style.border));

    // Render the visible text - buffer when focused, current value
    // when not.
    var disp_buf: [64]u8 = undefined;
    const display_text: []const u8 = if (is_focused)
        ctx.scalar_edit_buf[0..ctx.scalar_edit_len]
    else
        formatScalar(&disp_buf, T, value_ptr.*, opts.fmt);
    drawTextAtS(
        ctx,
        .{ box.x + padding[0], box.y + padding[1] },
        display_text,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = id;
    w.layout.last_item_rect = box;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, changed);
    return changed;
}

fn inputScalarImpl(
    ctx: *UiContext,
    label: []const u8,
    value_ptr: anytype,
    comptime T: type,
    opts: InputScalarOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const text_size: Vec2 = measureTextS(ctx, label);
    const box_h: f32 = ctx.style.font_size + 2 * padding[1];
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const reserve_label: f32 = text_size[0] + ctx.style.item_spacing[0];
    const default_box_w: f32 = @max(60, inner_right - at[0] - reserve_label);
    const box_w: f32 = effectiveItemWidth(ctx, opts.width, default_box_w);
    const box: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = box_w,
        .height = box_h,
    };

    const changed: bool = inputScalarInRect(ctx, w, id, box, value_ptr, T, opts);

    // Label after the box.
    if (label.len > 0) {
        drawTextAtS(
            ctx,
            .{ box.x + box.width + ctx.style.item_spacing[0], box.y + padding[1] },
            label,
            Color.toWire(ctx.style.text),
        );
    }

    advanceLayout(
        w,
        .{ box_w + reserve_label, box_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return changed;
}

fn inputDispatch(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    opts: InputScalarOpts,
) bool {
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.input: pass a single-item pointer (e.g., &my_value), got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    return switch (@typeInfo(Child)) {
        .float => inputScalarImpl(ctx, label, value, Child, opts),
        .int => inputScalarImpl(ctx, label, value, Child, opts),
        .array => |arr| blk: {
            switch (@typeInfo(arr.child)) {
                .float, .int => {},
                else => @compileError("ui.input: array element must be float or int, got " ++ @typeName(arr.child)),
            }
            break :blk inputArray(ctx, label, value, arr.child, arr.len, opts);
        },
        else => @compileError("ui.input: child type must be float, int, or [N]float/int, got " ++ @typeName(Child)),
    };
}

fn colorButtonImpl(
    ctx: *UiContext,
    desc_id: []const u8,
    rgba: [4]f32,
    size: Vec2,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, desc_id);

    const default_size: f32 = ctx.style.font_size + 2 * ctx.style.frame_padding[1];
    const sw_w: f32 = if (size[0] > 0) size[0] else default_size;
    const sw_h: f32 = if (size[1] > 0) size[1] else default_size;
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const swatch: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = sw_w,
        .height = sw_h,
    };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, swatch);
    if (hovered) {
        ctx.hovered_id = id;
    }
    const clicked: bool = hovered and ctx.input.mouse_left_clicked;

    // Render swatch + border.
    const swatch_color: Color = .{
        .r = @round(clamp(rgba[0], 0, 1) * 255),
        .g = @round(clamp(rgba[1], 0, 1) * 255),
        .b = @round(clamp(rgba[2], 0, 1) * 255),
        .a = @round(clamp(rgba[3], 0, 1) * 255),
    };
    drawRectFilled(ctx, swatch, Color.toWire(swatch_color));
    drawRect(ctx, swatch, Color.toWire(ctx.style.border));

    // Hover tooltip - RGBA breakdown.
    if (hovered) {
        const ui: Ui = .{ .ctx = ctx };
        ui.setTooltip("R:{d:.2} G:{d:.2} B:{d:.2} A:{d:.2}", .{
            rgba[0], rgba[1], rgba[2], rgba[3],
        });
    }

    w.layout.last_item_id = id;
    w.layout.last_item_rect = swatch;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);
    advanceLayout(
        w,
        .{ sw_w, sw_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return clicked;
}

fn colorButtonDispatch(
    ctx: *UiContext,
    desc_id: []const u8,
    value: anytype,
    size: Vec2,
) bool {
    const T = @TypeOf(value);
    const ti = @typeInfo(T);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("ui.colorButton: pass a single-item pointer (e.g., &my_color), got " ++ @typeName(T));
    }
    const Child: type = ti.pointer.child;
    var rgba_f: [4]f32 = undefined;
    if (Child == [3]f32) {
        rgba_f = .{ value[0], value[1], value[2], 1.0 };
    } else if (Child == [4]f32) {
        rgba_f = .{ value[0], value[1], value[2], value[3] };
    } else if (Child == Color) {
        rgba_f = .{
            float(value.r) / 255.0,
            float(value.g) / 255.0,
            float(value.b) / 255.0,
            float(value.a) / 255.0,
        };
    } else {
        @compileError("ui.colorButton: child must be [3]f32, [4]f32, or Color, got " ++ @typeName(Child));
    }
    return colorButtonImpl(ctx, desc_id, rgba_f, size);
}

fn imageButtonImpl(
    ctx: *UiContext,
    str_id: []const u8,
    source: anytype,
    size: ?Vec2,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, str_id);

    // Resolve texture id + natural size via the same dispatch the
    // existing `image` method uses.
    const T = @TypeOf(source);
    const default_natural_px: f32 = 64; // fallback when the source has no intrinsic size (raw GL id)
    var tex_id: u32 = 0;
    var natural: Vec2 = .{ default_natural_px, default_natural_px };
    var flipped: bool = false;
    switch (@typeInfo(T)) {
        .int, .comptime_int => {
            tex_id = @intCast(source);
        },
        .pointer => |p| switch (p.child) {
            types.Texture => {
                tex_id = source.id;
                natural = .{ @floatFromInt(source.width), @floatFromInt(source.height) };
            },
            RenderTexture => {
                tex_id = source.texture.id;
                natural = .{ @floatFromInt(source.texture.width), @floatFromInt(source.texture.height) };
                flipped = true;
            },
            else => @compileError(
                "ui.imageButton: pointer must target Texture or RenderTexture, got *" ++ @typeName(p.child),
            ),
        },
        .@"struct" => switch (T) {
            types.Texture => {
                tex_id = source.id;
                natural = .{ @floatFromInt(source.width), @floatFromInt(source.height) };
            },
            RenderTexture => {
                tex_id = source.texture.id;
                natural = .{ @floatFromInt(source.texture.width), @floatFromInt(source.texture.height) };
                flipped = true;
            },
            else => @compileError("ui.imageButton: unsupported struct type " ++ @typeName(T)),
        },
        else => @compileError("ui.imageButton: pass u32 GL ID, Texture, or RenderTexture, got " ++ @typeName(T)),
    }

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const eff_size: Vec2 = size orelse natural;
    const button_rect: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = eff_size[0],
        .height = eff_size[1],
    };

    const hovered: bool = pointInRect(ctx.input.mouse_pos, button_rect);
    if (hovered) {
        ctx.hovered_id = id;
    }
    const clicked: bool = hovered and ctx.input.mouse_left_clicked;

    // Render the texture using the same draw command imageImpl uses.
    // UV flip handled identically: RTs sample bottom-up, regular
    // textures top-down.
    const uv0: Vec2 = if (flipped) .{ 0, 1 } else .{ 0, 0 };
    const uv1: Vec2 = if (flipped) .{ 1, 0 } else .{ 1, 1 };
    drawTexturedQuad(ctx, button_rect, tex_id, uv0, uv1, 0xFFFFFFFF);
    // Hover border.
    if (hovered) {
        drawRect(ctx, button_rect, Color.toWire(ctx.style.button_hovered));
    }

    w.layout.last_item_id = id;
    w.layout.last_item_rect = button_rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);
    advanceLayout(
        w,
        eff_size,
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return clicked;
}

fn editStructImpl(
    ctx: *UiContext,
    value_ptr: anytype,
    opts: anytype,
) bool {
    const PtrT = @TypeOf(value_ptr);
    const ti = @typeInfo(PtrT);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("editStruct: pass a pointer to a struct, got " ++ @typeName(PtrT));
    }
    const StructT: type = ti.pointer.child;
    const struct_info = @typeInfo(StructT);
    if (struct_info != .@"struct") {
        @compileError("editStruct: child type must be a struct, got " ++ @typeName(StructT));
    }

    var any_changed: bool = false;
    inline for (struct_info.@"struct".field_names, struct_info.@"struct".field_types) |field_name, field_type| {
        // Per-field opts: empty struct if not mentioned.
        const has_opts: bool = comptime @hasField(@TypeOf(opts), field_name);
        const field_opts: @TypeOf(if (has_opts) @field(opts, field_name) else .{}) =
            if (has_opts) @field(opts, field_name) else .{};

        // `.skip = true` excludes the field from the editor.
        const skip: bool = blk: {
            if (comptime @hasField(@TypeOf(field_opts), "skip")) {
                break :blk field_opts.skip;
            }
            break :blk false;
        };
        if (skip) {
            continue;
        }

        // `.label` overrides the displayed label. Falls back to the
        // field's identifier as it appears in the source.
        const label: []const u8 = blk: {
            if (comptime @hasField(@TypeOf(field_opts), "label")) {
                break :blk field_opts.label;
            }
            break :blk field_name;
        };

        const field_ptr: @TypeOf(&@field(value_ptr.*, field_name)) = &@field(value_ptr.*, field_name);
        if (editFieldDispatch(ctx, label, field_type, field_ptr, field_opts)) {
            any_changed = true;
        }
    }
    return any_changed;
}

fn editArrayListImplOpts(
    ctx: *UiContext,
    label: []const u8,
    list_ptr: anytype,
    opts: anytype,
) bool {
    const PtrT = @TypeOf(list_ptr);
    const ti = @typeInfo(PtrT);
    if (ti != .pointer or ti.pointer.size != .one) {
        @compileError("editArrayList: pass a pointer to std.ArrayList(T), got " ++ @typeName(PtrT));
    }
    const ListT: type = ti.pointer.child;
    if (!@hasField(ListT, "items")) {
        @compileError("editArrayList: child must be std.ArrayList(T) (no `items` field), got " ++ @typeName(ListT));
    }
    // `list.items` is `[]T`; extract T from it.
    const items_field = @TypeOf(@as(ListT, undefined).items);
    const items_info: std.builtin.Type = @typeInfo(items_field);
    if (items_info != .pointer or items_info.pointer.size != .slice) {
        @compileError("editArrayList: list.items is not a slice (got " ++ @typeName(items_field) ++ ")");
    }
    const T: type = items_info.pointer.child;

    const ui: Ui = .{ .ctx = ctx };
    if (!ui.treeNode(label, .{})) {
        return false;
    }
    defer ui.treePop();

    var any_changed: bool = false;

    // Header row: count + "Add" button.
    ui.text("count: {d}", .{list_ptr.items.len});
    ui.sameLine(.{});
    const add_clicked: bool = ui.button("+ Add", .{});
    if (add_clicked) {
        const default_item: T = std.mem.zeroes(T);
        list_ptr.append(ctx.gpa, default_item) catch {};
        any_changed = true;
    }

    // Per-element rows. Defer remove to after the loop so we don't
    // mutate the slice we're iterating.
    var to_remove: ?usize = null;
    var i: usize = 0;
    while (i < list_ptr.items.len) : (i += 1) {
        // Scope IDs by index so per-element widgets get unique IDs.
        ui.pushIdInt(@intCast(i));
        defer ui.popId();

        var row_label_buf: [16]u8 = undefined;
        const row_label: []const u8 = bufPrint(&row_label_buf, "[{d}]", .{i}) catch row_label_buf[0..0];

        // Element editor - uses same dispatch as editStruct. Pass
        // `opts` through so struct elements can have per-field
        // constraints applied uniformly to every row.
        const elem_changed: bool = editFieldDispatch(ctx, row_label, T, &list_ptr.items[i], opts);
        if (elem_changed) {
            any_changed = true;
        }

        // Per-row "Remove" button on the same line as the editor's
        // last item. Note: for nested structs (which open a tree
        // node), the X button appears on the row where the tree
        // node header was - not perfect but acceptable for v1.
        ui.sameLine(.{});
        const remove_clicked: bool = ui.button("x", .{});
        if (remove_clicked) {
            to_remove = i;
        }
    }

    if (to_remove) |r| {
        _ = list_ptr.orderedRemove(r);
        any_changed = true;
    }

    return any_changed;
}

fn editArrayListImpl(
    ctx: *UiContext,
    label: []const u8,
    list_ptr: anytype,
) bool {
    return editArrayListImplOpts(ctx, label, list_ptr, .{});
}

fn showLayoutTab(self: Ui, state: *DemoState) void {
    self.text("Same-line layout", .{});
    _ = self.button("A", .{});
    self.sameLine(.{});
    _ = self.button("B", .{});
    self.sameLine(.{});
    _ = self.button("C", .{});

    if (state.layout_show_separator) {
        self.separator();
    }
    _ = self.checkbox("Show separators", &state.layout_show_separator);

    self.text("Indent / unindent", .{});
    _ = self.checkbox("Outer", &state.layout_indent_a);
    self.indent();
    _ = self.checkbox("Inner", &state.layout_indent_b);
    self.unindent();

    self.separator();
    self.text("Tree node demo", .{});
    if (self.treeNode("Click to expand", .{})) {
        defer self.treePop();
        self.text("This is inside the tree.", .{});
        if (self.treeNode("Deeper", .{})) {
            defer self.treePop();
            self.text("And this is even deeper.", .{});
        }
    }

    self.separator();
    self.text("Group + child", .{});
    self.beginGroup();
    self.text("In a group", .{});
    _ = self.button("Group btn", .{});
    self.endGroup();
}

fn showAboutTab(self: Ui) void {
    self.text("zimr - pure-Zig raylib + ImGui port", .{});
    self.text("Targets wasm32-wasi + WebGL2 and native.", .{});
    self.separator();
    self.text("Documentation: see /src/notes/architecture.md", .{});
    self.text("Cheatsheet: zimr-cheatsheet.html", .{});
    self.separator();
    self.text("This window itself is rendered via the same widgets", .{});
    self.text("you see in the Widgets tab - it's a self-portrait.", .{});
}

fn showDemoWindowImpl(
    self: Ui,
    state: *DemoState,
    p_open: ?*bool,
) void {
    if (p_open) |op| {
        if (!op.*) {
            return;
        }
    }

    self.setNextWindowSize(.{ 540, 480 }, .{ .once = true });
    if (self.window("Dear ZIMR - demo", .{})) |w| {
        defer w.close();

        self.text("zimr {s} - every widget the port supports.", .{"6.0"});
        self.text("Press Tab / Shift+Tab to move keyboard focus.", .{});
        self.separator();

        if (self.beginTabBar("##demo_tabs", .{})) {
            defer self.endTabBar();
            if (self.beginTabItem("Widgets", null, .{})) {
                defer self.endTabItem();
                showWidgetsTab(self, state);
            }
            if (self.beginTabItem("Layout", null, .{})) {
                defer self.endTabItem();
                showLayoutTab(self, state);
            }
            if (self.beginTabItem("Style", null, .{})) {
                defer self.endTabItem();
                _ = self.styleEditor();
            }
            if (self.beginTabItem("About", null, .{})) {
                defer self.endTabItem();
                showAboutTab(self);
            }
        }
    }
}

/// Open a tooltip block. Lazily allocates the tooltip window on
/// first call, then reuses it across frames. Routes subsequent
/// widget submission into the tooltip's draw list by swapping
/// `ctx.current_window` and `ctx.current_draw_list`.
fn beginTooltipImpl(ctx: *UiContext) void {
    // Lazy alloc on first ever begin - outlives the call by sitting
    // on the ctx, freed in UiContext.deinit.
    if (ctx.tooltip_window == null) {
        const tw: *Window = ctx.gpa.create(Window) catch return;
        tw.* = .{
            .id = 0xFFFFFFFE, // reserved id for the tooltip window
            .pos = .{ 0, 0 },
            .size = .{ 0, 0 },
        };
        ctx.tooltip_window = tw;
    }
    const tw: *Window = ctx.tooltip_window.?;

    // Position at mouse + (16, 16) offset. Size is set at
    // endTooltip from accumulated cursor_max. Background drawn
    // there too.
    const padding: Vec2 = ctx.style.frame_padding;
    tw.pos = .{ ctx.input.mouse_pos[0] + 16, ctx.input.mouse_pos[1] + 16 };
    tw.size = .{ 9999, 9999 }; // huge - widgets won't wrap
    // Reset per-frame layout state; the tooltip is a fresh
    // submission every frame. The draw list arena was just reset
    // in endFrame, so `cmds =.empty` is the right cleanup.
    tw.draw_list.clear();
    tw.layout.cursor_pos = .{ tw.pos[0] + padding[0], tw.pos[1] + padding[1] };
    tw.layout.cursor_max = tw.layout.cursor_pos;
    tw.layout.line_height = 0;

    // Save the current window context so endTooltip can pop. We
    // stash them on the tooltip window itself for the duration of
    // the block - no separate stack needed since nested tooltips
    // aren't supported.
    tw.saved_window_ptr = ctx.current_window;
    tw.saved_draw_list = ctx.current_draw_list;

    ctx.current_window = tw;
    ctx.current_draw_list = &tw.draw_list;
    ctx.tooltip_active_this_frame = true;
}

/// Close the tooltip block. Sizes the tooltip rect to fit the
/// submitted content; the actual frame background is drawn at
/// endFrame in renderTooltipBlock.
fn endTooltipImpl(ctx: *UiContext) void {
    if (!ctx.tooltip_active_this_frame) {
        return;
    }
    const tw: *Window = ctx.tooltip_window orelse return;
    const padding: Vec2 = ctx.style.frame_padding;
    tw.size = .{
        (tw.layout.cursor_max[0] - tw.pos[0]) + padding[0],
        (tw.layout.cursor_max[1] - tw.pos[1]) + padding[1],
    };
    // Floor - degenerate-content case (no widgets between begin/end)
    // still gets a visible box so users notice the call.
    if (tw.size[0] < 8) {
        tw.size[0] = 8;
    }
    if (tw.size[1] < 8) {
        tw.size[1] = 8;
    }

    ctx.current_window = tw.saved_window_ptr;
    ctx.current_draw_list = tw.saved_draw_list;
}

/// Virtualized iterator for long item lists. Submitted from
/// inside an active window scope; reserves the FULL content
/// height up-front via `dummy`-style cursor advancement so the
/// scrollbar reflects the total range, then on `step()` returns
/// only the row range intersecting the visible area.
/// The single-step model: `step()` returns one Range covering
/// every visible row, then null. ImGui's clipper supports
/// multi-step for variable item heights (a feature we don't yet
/// support - A2 covers known-height lists, which is ~90% of real
/// uses including log viewers, scoreboards, file lists).
pub const Clipper = struct {
    ctx: *UiContext,
    item_count: usize,
    item_height: f32,
    /// Y position where row 0 would have been placed without the
    /// clipper, in screen coordinates. Captured at init from
    /// the resolved cursor position.
    origin_y: f32,
    /// Top edge of the visible viewport in screen coords.
    viewport_top: f32,
    /// Bottom edge of the visible viewport in screen coords.
    viewport_bot: f32,
    /// Inclusive start row index of the visible range, computed
    /// at init. Same as Range.start returned by the first step.
    visible_start: usize,
    /// Exclusive end row index of the visible range.
    visible_end: usize,
    /// State machine for `step()`:
    ///   .not_started → return visible range, advance to.stepped
    ///   .stepped → end-of-iteration cleanup, advance to.done, return null
    ///   .done → return null
    state: State = .not_started,

    pub const Range = struct {
        start: usize,
        end: usize, // exclusive
    };

    const State = enum { not_started, stepped, done };

    fn init(
        ctx: *UiContext,
        item_count: usize,
        item_height: f32,
    ) Clipper {
        const h: f32 = if (item_height > 0) item_height else 1;
        const w_opt: ?*Window = ctx.current_window;
        if (w_opt == null or item_count == 0) {
            return .{
                .ctx = ctx,
                .item_count = 0,
                .item_height = h,
                .origin_y = 0,
                .viewport_top = 0,
                .viewport_bot = 0,
                .visible_start = 0,
                .visible_end = 0,
                .state = .done,
            };
        }
        const w: *Window = w_opt.?;

        // The cursor is currently where row 0 would be placed.
        // Resolve any pending sameLine so we see the actual y.
        const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
        const origin_y: f32 = at[1];

        // Visible viewport in screen coords - the vertical bounds
        // of the CURRENT scope's content area. After's
        // child-as-Window refactor, `current_window` is the child
        // window itself when inside `beginChild` (with its own
        // `pos` / `size` / `layout`), so the viewport math is the
        // same regardless of nesting:
        //   vtop = top of content area = layout.origin.y
        //   vbot = bottom of content area = layout.work_rect_max.y
        // These were set correctly at `openWindow` (for top-level)
        // and `beginChildImpl` (for children). No more
        // `child_stack` peek needed - `layout` already encodes the
        // tighter bound when we're inside a child.
        const vtop: f32 = w.layout.origin[1];
        const vbot: f32 = w.layout.work_rect_max[1];

        // Compute first/last visible row indices.
        const visible_height: f32 = @max(0, vbot - vtop);
        const first_visible_f: f32 = @max(0, (vtop - origin_y) / h);
        var first_visible: usize = @floor(first_visible_f);
        const visible_rows_f: f32 = @ceil(visible_height / h) + 1; // +1 for partial rows at top + bottom
        var visible_rows: usize = @trunc(visible_rows_f);
        // Add 1 extra row of overscan top + bottom so a row that's
        // 1 pixel above the viewport still renders (avoids a
        // flicker at boundaries).
        if (first_visible > 0) {
            first_visible -= 1;
            visible_rows += 1;
        }
        visible_rows += 1;

        var last_visible: usize = first_visible + visible_rows;
        if (last_visible > item_count) {
            last_visible = item_count;
        }
        if (first_visible > item_count) {
            first_visible = item_count;
        }

        return .{
            .ctx = ctx,
            .item_count = item_count,
            .item_height = h,
            .origin_y = origin_y,
            .viewport_top = vtop,
            .viewport_bot = vbot,
            .visible_start = first_visible,
            .visible_end = last_visible,
            .state = .not_started,
        };
    }

    /// Returns the row range to render this step, or null when
    /// done. Caller-side pattern:
    ///     while (clip.step()) |r| {... render rows r.start..r.end... }
    /// On the first call (state ==.not_started):
    ///   - If no rows are visible, advance cursor past the entire
    ///     content height and return null.
    ///   - Otherwise advance cursor to the visible-start row's
    ///     position and return the visible range.
    /// On the second call (state ==.stepped):
    ///   - Advance cursor past the remaining content (rows after
    ///     visible_end through item_count). Return null.
    pub fn step(self: *Clipper) ?Range {
        const w: *Window = self.ctx.current_window orelse {
            self.state = .done;
            return null;
        };
        switch (self.state) {
            .not_started => {
                if (self.visible_start >= self.visible_end) {
                    // Nothing visible - advance to total content height and finish.
                    const total_h: f32 = self.item_height * float(self.item_count);
                    w.layout.cursor_pos[1] += total_h;
                    if (w.layout.cursor_pos[1] > w.layout.cursor_max[1]) {
                        w.layout.cursor_max[1] = w.layout.cursor_pos[1];
                    }
                    self.state = .done;
                    return null;
                }
                // Advance cursor down to where the first visible row should sit.
                const skip_pixels: f32 = self.item_height * float(self.visible_start);
                w.layout.cursor_pos[1] += skip_pixels;
                self.state = .stepped;
                return Range{ .start = self.visible_start, .end = self.visible_end };
            },
            .stepped => {
                // Caller has just submitted rows visible_start..visible_end.
                // The window's cursor is now at visible_end's bottom edge.
                // Advance past the remaining rows so the window's
                // total content extent reflects all `item_count` rows.
                const remaining: usize = self.item_count - self.visible_end;
                const remaining_h: f32 = self.item_height * float(remaining);
                w.layout.cursor_pos[1] += remaining_h;
                if (w.layout.cursor_pos[1] > w.layout.cursor_max[1]) {
                    w.layout.cursor_max[1] = w.layout.cursor_pos[1];
                }
                self.state = .done;
                return null;
            },
            .done => return null,
        }
    }

    /// Manual termination, in case the caller breaks out of the
    /// step loop early. Equivalent to draining `step()` until it
    /// returns null. Safe to call multiple times. ImGui: `End()`.
    pub fn end(self: *Clipper) void {
        while (self.step()) |_| {}
    }
};

pub const Ui = struct {
    ctx: *UiContext,

    // ---- Window scope
    /// Open a window scope. Returns a handle if the window is
    /// "alive" this frame (visible + uncollapsed). Caller pattern:
    ///     if (f.ui.window("Stats",.{})) |w| {
    ///         defer w.close();
    ///         f.ui.text("FPS: {d}",.{f.clock.fps()});
    ///     }
    /// Every window is uncollapsed and at a default position; the
    /// optional return type carries the API shape for collapse /
    /// dismiss / hide flags.
    pub fn window(
        self: Ui,
        title: []const u8,
        opts: WindowOpts,
    ) ?WindowHandle {
        return openWindow(self.ctx, title, opts);
    }

    /// Begin a child window scope inside the current window. Widgets
    /// submitted between `beginChild` / `endChild` are clipped to the
    /// child's bounds via the draw list's `push_clip` command, run
    /// their own layout from the child's top-left, and have their IDs
    /// hashed under `str_id` (so identical labels in different
    /// children don't collide).
    ///     if (f.ui.beginChild("log",.{ 0, 120 },.{.border = true })) {
    ///         defer f.ui.endChild();
    ///         for (lines) |line| f.ui.text("{s}",.{line});
    ///     }
    /// Layout: the child consumes its OUTER size from the parent's
    /// cursor - subsequent parent widgets sit below it. Width 0
    /// auto-fills to the parent's content right edge; height 0
    /// defaults to a comfortable 100px (caller can pass an explicit
    /// height for log windows / lists / preview panes).
    /// **Why this matters architecturally** - child windows are the
    /// foundation for tables (each scrollable area is a child) and
    /// for log/list views. Getting clipping right here proves the
    /// deferred-draw-list scissor pipeline works correctly in
    /// nested cases.
    pub fn beginChild(
        self: Ui,
        str_id: []const u8,
        size: Vec2,
        flags: ChildFlags,
    ) bool {
        return beginChildImpl(self.ctx, str_id, size, flags);
    }

    /// End the current child scope. Restores the parent's layout
    /// cursor and pops the clip rect from the draw list. Symmetric
    /// with `beginChild` - call EXACTLY once per `beginChild` that
    /// returned true. The `defer f.ui.endChild()` pattern is the
    /// right way to spell that.
    pub fn endChild(self: Ui) void {
        endChildImpl(self.ctx);
    }

    // ---- Docking
    /// Submit a dockspace within the current scope. Reserves
    /// `size` worth of layout space, looks up (or creates) the
    /// root `DockNode` for `str_id`, returns its `Id`. The
    /// returned id is stable across frames and can be passed to
    /// `dockBuilderDockWindow` / `dockBuilderSplitNode` to define
    /// the default layout.
    /// `size`: outer dimensions of the dockspace. Pass
    /// `Ui.contentRegionAvail()` to fill the remaining space of
    /// the containing window - typical pattern for "the whole
    /// window IS a dockspace."
    /// `opts.flags`: not yet used; reserved for future
    /// `passthru_central_node`, `no_split`, `no_resize` etc.
    /// matching imgui's `ImGuiDockNodeFlags_*`.
    /// What renders today (5.5b): a single empty container outline
    /// where docked windows WILL appear. 5.5c wires
    /// `Window.dock_node_id` so the actual docked-window rendering
    /// happens. 5.5d/e add splitters and drag-targets.
    /// Returns 0 if no `current_window` is set (must be called
    /// inside a `window()` scope).
    pub fn dockSpace(
        self: Ui,
        str_id: []const u8,
        size: Vec2,
        opts: DockSpaceOpts,
    ) Id {
        if (self.ctx.current_window) |hw| {
            hw.hosts_dockspace = true;
        }
        return dockSpaceImpl(self.ctx, str_id, size, opts);
    }

    // ---- Docking builder API
    // Programmatic layout construction. Used by demos to set up a
    // default arrangement at startup. Once the user has interacted
    // with the layout (drag-dock, resize, etc.) the builder calls
    // become no-ops in practice - `dockBuilderDockWindow` only
    // applies to a window that doesn't yet have a `dock_node_id`,
    // so calling it on an already-docked window is a no-op. Demos
    // typically gate the builder block behind a "first frame"
    // check to avoid stomping on user interaction.
    // The standard pattern (in a demo's update fn):
    //     const root = u.dockSpace("MainDockSpace", size,.{});
    //     if (state.first_frame) {
    //         const a_b = u.dockBuilderSplitNode(root,.left, 0.25);
    //         u.dockBuilderDockWindow("Tools", a_b.a);
    //         u.dockBuilderDockWindow("Viewport", a_b.b);
    //         u.dockBuilderFinish(root);
    //         state.first_frame = false;
    //     }

    /// Split `node_id` along `dir` with the given `ratio`. Returns
    /// the two new child node ids. Existing window list (if any)
    /// is preserved on the side OPPOSITE `dir` - e.g. splitting
    /// `.left` with windows already docked puts the existing
    /// content on the right (b), with a fresh empty leaf on the
    /// left (a) waiting for new windows.
    /// Returns `.{.a = 0,.b = 0 }` on error (invalid node id,
    /// target is already a split, etc). Callers can detect by
    /// checking whether `a` is zero.
    pub fn dockBuilderSplitNode(
        self: Ui,
        node_id: Id,
        dir: Dir,
        ratio: f32,
    ) SplitResult {
        const result: SplitResult = splitNode(
            &self.ctx.dock,
            self.ctx.gpa,
            node_id,
            dir,
            ratio,
        ) catch {
            return .{ .a = 0, .b = 0 };
        };
        return .{ .a = result.a, .b = result.b };
    }

    /// Dock the window named `window_name` into the leaf node
    /// `node_id`. Creates the window if it doesn't exist yet
    /// (with default opts; the demo's subsequent `u.window(name,...)`
    /// call will pick up the same window via name lookup).
    /// No-op when the window is already docked into `node_id`
    /// (idempotent - safe to call from inside the demo's
    /// per-frame builder block without gating). When the window
    /// is docked elsewhere, it's first undocked then re-docked.
    pub fn dockBuilderDockWindow(
        self: Ui,
        window_name: []const u8,
        node_id: Id,
    ) void {
        const ctx: *UiContext = self.ctx;
        const target: *DockNode = ctx.dock.lookup(node_id) orelse return;
        if (!target.isLeaf()) {
            return;
        }

        // Find or create the persistent Window via the standard
        // name-hash path. Pass minimal opts - pos/size will be
        // overridden by the leaf rect each frame anyway.
        const w: *Window = findOrCreateWindow(ctx, window_name, .{});

        // Idempotent fast path.
        if (w.dock_node_id == node_id) {
            return;
        }

        // If docked elsewhere, undock first to keep the dock
        // layer's window→leaf inverse correct. `undockWindow`
        // walks the leaves to find the window - we don't need to
        // pass the old leaf id.
        if (w.dock_node_id != null) {
            _ = undockWindow(&ctx.dock, ctx.gpa, w.id);
        }

        // stash pre-dock pos/size on the
        // FIRST docking event, so undocking later restores the
        // window to where the user had it. Subsequent re-docks
        // (e.g. moving from one leaf to another via drag) keep
        // the existing stash - the pre-dock anchor is the spot
        // the user originally chose. Clear only on actual
        // undock-to-floating in `clearDockIdsInSubtree` /
        // `processRequests`'s undock handler.
        if (w.pre_dock_pos == null) {
            w.pre_dock_pos = w.pos;
            w.pre_dock_size = w.size;
        }

        // Wire the window into the new leaf, both directions.
        dockWindowAsTab(&ctx.dock, ctx.gpa, node_id, w.id) catch return;
        w.dock_node_id = node_id;
    }

    /// Remove a dock subtree. Tears down `node_id` and every
    /// node beneath it; the parent (if any) collapses to a leaf
    /// with the surviving sibling's contents. Windows that were
    /// in the removed subtree have their `dock_node_id` cleared
    /// (they fall back to floating).
    /// Used by demos to reset a layout: typically called once,
    /// followed by a fresh sequence of split + dockWindow calls.
    pub fn dockBuilderRemoveNode(self: Ui, node_id: Id) void {
        const ctx: *UiContext = self.ctx;
        // Walk every window and clear `dock_node_id` if its leaf
        // is part of the subtree being removed. Done BEFORE the
        // removal so we can still resolve ancestry; afterward the
        // nodes are gone.
        clearDockIdsInSubtree(ctx, node_id);
        removeNode(&ctx.dock, ctx.gpa, node_id);
    }

    /// Finalize the layout. Currently a no-op marker - our
    /// `layoutSubtree` runs every frame in `dockSpaceImpl` regardless,
    /// so there's nothing to "commit". Reserved for future
    /// optimization where `dockBuilderFinish` would write into a
    /// frozen cache rather than re-walking every frame.
    pub fn dockBuilderFinish(self: Ui, node_id: Id) void {
        _ = self;
        _ = node_id;
    }

    /// set the constraint flags on a dock
    /// node. Mirrors imgui's `DockBuilderGetNode(id)->LocalFlags
    /// =...` pattern, but in a single explicit call. No-op if
    /// the node id is unknown.
    /// Example: lock down a side panel so the user can't dock
    /// other windows over it, split it, or accidentally close
    /// its tabs:
    ///     u.dockBuilderSetNodeFlags(side_id,.{
    ///         .no_split = true,
    ///         .no_docking_over_me = true,
    ///         .no_close_button = true,
    ///     });
    pub fn dockBuilderSetNodeFlags(
        self: Ui,
        node_id: Id,
        flags: DockNodeFlags,
    ) void {
        const node: *DockNode = self.ctx.dock.lookup(node_id) orelse return;
        node.flags = flags;
        node.generation +%= 1;
    }

    /// Read the current flags on a dock node. Returns
    /// default-init (all false) for unknown ids - callers don't
    /// need to handle the null case.
    pub fn dockBuilderGetNodeFlags(
        self: Ui,
        node_id: Id,
    ) DockNodeFlags {
        const node: *DockNode = self.ctx.dock.lookup(node_id) orelse return .{};
        return node.flags;
    }

    /// lock one child of a SPLIT node to a
    /// fixed pixel size along the split axis. The other child
    /// absorbs the dockspace remainder when the host resizes.
    /// This is the "Tools panel stays 200px wide regardless of
    /// window size" UX.
    ///   `parent_id`: the SPLIT node (NOT the child leaf).
    ///   `side`: `.a` = top/left child; `.b` = bottom/right.
    ///   `px`: target pixel size; `null` clears the lock and
    ///         falls back to `ratio`-driven sizing.
    /// Locking both children at once is over-constrained; the
    /// layout falls back to `ratio` in that case (same behavior
    /// as no lock). No-op for leaf nodes or unknown ids.
    /// Splitter drag on a node with an active lock writes the
    /// new pixel size back into `size_ref` (preserving the lock
    /// semantic) instead of mutating `ratio`.
    pub fn dockBuilderSetSizeRef(
        self: Ui,
        parent_id: Id,
        side: enum { a, b },
        px: ?f32,
    ) void {
        const node: *DockNode = self.ctx.dock.lookup(parent_id) orelse return;
        if (node.split == null) {
            return;
        }
        const idx: usize = if (side == .a) 0 else 1;
        node.split.?.size_ref[idx] = px;
        node.generation +%= 1;
    }

    /// mark a node as "central" - the one
    /// that absorbs remaining space when its siblings are locked
    /// at fixed sizes. Pure marker; sets `is_central = true` in
    /// the node's flags. Today the layout doesn't yet special-
    /// case central nodes (the `size_ref` mechanism already
    /// gives the "fixed sibling + free central" UX); the flag
    /// reservation is for 5.5k persistence and future drop-zone
    /// behavior (`NoDockingOverCentralNode` semantics).
    pub fn dockBuilderSetCentralNode(
        self: Ui,
        node_id: Id,
    ) void {
        const node: *DockNode = self.ctx.dock.lookup(node_id) orelse return;
        node.flags.is_central = true;
        node.generation +%= 1;
    }

    // ---- ID stack
    pub fn pushId(self: Ui, str_id: []const u8) void {
        const top: Id = if (self.ctx.id_stack.len > 0)
            self.ctx.id_stack.items[self.ctx.id_stack.len - 1]
        else
            0;
        const id: Id = hashStr(top, str_id);
        self.ctx.id_stack.append(id) catch return;
    }

    pub fn pushIdInt(self: Ui, int_id: i64) void {
        const top: Id = if (self.ctx.id_stack.len > 0)
            self.ctx.id_stack.items[self.ctx.id_stack.len - 1]
        else
            0;
        const id: Id = hashInt(top, int_id);
        self.ctx.id_stack.append(id) catch return;
    }

    pub fn popId(self: Ui) void {
        _ = self.ctx.id_stack.pop();
    }

    pub fn getId(self: Ui, str_id: []const u8) Id {
        const top: Id = if (self.ctx.id_stack.len > 0)
            self.ctx.id_stack.items[self.ctx.id_stack.len - 1]
        else if (self.ctx.current_window) |w|
            w.id
        else
            0;
        return hashStr(top, str_id);
    }

    // ---- Layout --------------------------------------------------- primitives
    /// Place the next widget on the same row as the last. Optional
    /// `offset_x` overrides the default cursor advance.
    /// Place the next widget on the same row as the previous one,
    /// just to its right (separated by `item_spacing[0]`, or at an
    /// explicit X if `opts.offset_x` is non-zero). Matches imgui's
    /// `SameLine` semantics - specifically:
    /// - cursor X = right edge of the previous row's items
    ///   (`cursor_pos_prev_line[0]`) + spacing.
    /// - cursor Y = top of the previous row
    ///   (`cursor_pos_prev_line[1]`).
    /// - `line_height` = `prev_line_height` so the row's max-height
    ///   tracking continues accumulating as the next widget submits.
    /// The new widget submits at the resolved cursor; on its
    /// `advanceLayout`, `cursor_pos_prev_line` and `prev_line_height`
    /// are updated to include it, and the cursor wraps to the next
    /// row past the tallest widget on this row.
    pub fn sameLine(self: Ui, opts: SameLineOpts) void {
        const w: *Window = self.ctx.current_window orelse return;
        const s: *LayoutScope = &w.layout;
        const item_sp_x: f32 = self.ctx.style.item_spacing[0];
        // `opts.offset_x` is a logical content X (imgui's
        // SetCursorPosX semantics: "X from the window's start,
        // not from the visible left edge").  Convert to screen
        // by adding origin and subtracting scroll, matching
        // imgui's `window->Pos.x - window->Scroll.x + x`.
        s.cursor_pos[0] = if (opts.offset_x > 0)
            s.origin[0] - w.scroll_x + opts.offset_x
        else
            s.cursor_pos_prev_line[0] + item_sp_x;
        s.cursor_pos[1] = s.cursor_pos_prev_line[1];
        s.line_height = s.prev_line_height;
    }

    /// Vertical separator. Fills the content width at current cursor.
    pub fn separator(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        const start: Vec2 = self.cursorScreenPos();
        const end_x: f32 = w.pos[0] + w.size[0] - self.ctx.style.window_padding[0];
        drawRectFilled(
            self.ctx,
            .{ .x = start[0], .y = start[1], .width = end_x - start[0], .height = 1 },
            Color.toWire(self.ctx.style.border),
        );
        // Advance one pixel + spacing
        const consumed = Vec2{ end_x - start[0], 1 };
        advanceLayout(w, consumed, self.ctx.style.item_spacing, self.ctx.style.window_padding[0]);
    }

    /// Add empty space on the current line, then start a new line.
    pub fn spacing(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        advanceLayout(
            w,
            .{ 0, self.ctx.style.item_spacing[1] },
            self.ctx.style.item_spacing,
            self.ctx.style.window_padding[0],
        );
    }

    // ---- Widgets
    pub fn button(
        self: Ui,
        label: []const u8,
        opts: ButtonOpts,
    ) bool {
        return buttonImpl(self.ctx, label, opts);
    }

    /// Compact button - zero vertical frame padding, just label-tall.
    /// Useful inline with text for actions like "?" or "[x]" markers,
    /// or in dense toolbars where vertical space matters.
    /// ImGui: `SmallButton(label)`.
    pub fn smallButton(self: Ui, label: []const u8) bool {
        return smallButtonImpl(self.ctx, label);
    }

    /// Tiny directional button (square, font-sized, with a triangle
    /// glyph inside pointing in `dir`). Used for inline ±1 steppers
    /// next to numeric inputs, or for tab-bar / combo nav glyphs.
    /// `str_id` is the widget's unique label for state tracking; it
    /// is NOT drawn - only the arrow glyph is. ImGui:
    /// `ArrowButton(str_id, dir)`.
    pub fn arrowButton(
        self: Ui,
        str_id: []const u8,
        dir: ArrowDir,
    ) bool {
        return arrowButtonImpl(self.ctx, str_id, dir);
    }

    pub fn text(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        textImpl(self.ctx, fmt, args, self.ctx.style.text);
    }

    /// Like `text`, but with an explicit color. Convenience for
    /// status messages, error highlights, headings.
    pub fn textColored(
        self: Ui,
        color: Color,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        textImpl(self.ctx, fmt, args, color);
    }

    /// Like `text`, but soft-wraps at the right edge of the
    /// available content area. Wraps on word boundaries when
    /// possible, falls back to mid-word splits if a single token is
    /// longer than the line. No allocation: writes into the per-
    /// frame stack buffer.
    pub fn textWrapped(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        textWrappedImpl(self.ctx, fmt, args);
    }

    /// Render `label` on the left + formatted value on the right
    /// of the same row, with a small gap between. Common pattern
    /// for key/value displays ("Health: 100", "Status: ready").
    /// The label is in normal text color; the formatted value uses
    /// it too. For value-emphasis, wrap the args side in your own
    /// `text` / `textColored` calls; this is the imgui-parity
    /// convenience for the common "Label: value" form.
    pub fn labelText(
        self: Ui,
        label: []const u8,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        labelTextImpl(self.ctx, label, fmt, args);
    }

    /// Like `text`, but rendered in the dimmed `style.text_disabled`
    /// color. Use for hints, secondary info, captions - anything
    /// the eye should pass over before the main content.
    pub fn textDisabled(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        textImpl(self.ctx, fmt, args, self.ctx.style.text_disabled);
    }

    // ---- text helpers + Value + invisibleButton
    // Five small additions from imgui's Tier-3 widget set + one tiny gap-fill
    // (setItemTooltip). See `src/notes/imgui-plan.md` step 1.1.

    /// Section-header rule with a centered label. Renders as a short
    /// rule on the left, the `label` in normal text colour, then a long
    /// rule filling to the right edge of the window's work area. If
    /// `label` is empty, degrades to a plain full-width rule at the row's
    /// vertical centre (slightly heavier than `separator` - 2px vs 1px).
    /// ImGui: `SeparatorText(label)`.
    /// Use for top-level panel sections inside a window - like the
    /// "Controls / Log / Status" bands in the log viewer demo.
    pub fn separatorText(self: Ui, label: []const u8) void {
        separatorTextImpl(self.ctx, label);
    }

    /// Click-target rectangle with NO visual. Same hit-testing as
    /// `button` but doesn't draw a frame or label - useful for drag
    /// handles, hidden hit zones, "tap-anywhere-to-dismiss" overlays,
    /// or clickable banners where you draw your own visuals.
    /// Returns true on press (mouse-up while hovered). ImGui:
    /// `InvisibleButton(str_id, size, flags)`.
    /// Size semantics: a zero on either axis means "use a sensible
    /// default" - for X, fill the remaining row width to the right
    /// padding; for Y, the standard widget row height
    /// (`font_size + 2 * frame_padding[1]`). This mirrors imgui's
    /// `0 → -FLT_MIN → fill` convention.
    /// `id_str` disambiguates this hit zone from other invisible
    /// regions in the same window - it's hashed into the widget ID
    /// stack but never rendered.
    pub fn invisibleButton(
        self: Ui,
        id_str: []const u8,
        size: Vec2,
    ) bool {
        return invisibleButtonImpl(self.ctx, id_str, size);
    }

    /// Click-styled text. Renders `label` in `style.text_link` with a
    /// 1px underline below, switches to `style.text_link_hovered` and
    /// shows the pointing-hand cursor while hovered, and returns true
    /// on press. Use for in-text links that trigger an in-app action
    /// (open a panel, focus a widget, etc.) - for "open this URL"
    /// links, use `textLinkOpenURL` instead. ImGui: `TextLink(label)`.
    pub fn textLink(self: Ui, label: []const u8) bool {
        return textLinkImpl(self.ctx, label);
    }

    /// Click-styled text that opens `url` in a new browser tab on
    /// press. Hovering shows a tooltip with the destination URL so the
    /// user always sees where the link goes before clicking. Right-
    /// click pops a "Copy link" context menu (currently desktop-only
    /// phone support arrives with step 2.2's long-press bridge).
    /// Returns true on press for the rare caller that wants to react
    /// in-app to the click in addition to opening the URL.
    /// ImGui: `TextLinkOpenURL(label, url)`.
    /// Implementation is a composer over public methods - `textLink`
    /// for click semantics, `setItemTooltip` for the URL preview,
    /// `beginPopupContextItem` + `menuItem` for the right-click menu.
    /// Browsers may block the `window.open` call if it doesn't fire
    /// inside a user-gesture handler (the `runtime.core.openURL` path
    /// goes through the JS bridge from `web.zig`); a click on this
    /// widget IS a user gesture so the normal path works.
    pub fn textLinkOpenURL(
        self: Ui,
        label: []const u8,
        url: []const u8,
    ) bool {
        const pressed: bool = textLinkImpl(self.ctx, label);
        if (pressed) {
            runtime.core.openURL(url);
        }
        // Tooltip on hover. Always show the URL - even when the label
        // happens to equal the URL, repetition is friendlier than
        // confusion ("did I click the right one?").
        self.setItemTooltip("Open in browser:\n{s}", .{url});
        // Right-click → Copy link. Auto-opens on right-click hover via
        // `beginPopupContextItem`; on phone this never fires today since
        // touch only emits left-click events. lands the long-
        // press → right-click bridge that makes this reachable on phone.
        if (self.beginPopupContextItem(null)) {
            defer self.endPopup();
            if (self.menuItem("Copy link", .{})) {
                self.setClipboardText(url);
            }
        }
        return pressed;
    }

    /// Show a tooltip on hover, automatically gated to "only if the
    /// last-submitted item is hovered this frame." Saves the two-line
    /// `if (isItemHovered()) setTooltip(...)` boilerplate at the call
    /// site - and that boilerplate is the typical pattern, so most
    /// tooltips should use this. ImGui: `SetItemTooltip(fmt,...)`.
    /// Was missing from the zimr surface before now (v2 audit §C23
    /// listed it ❌); filled here because `textLinkOpenURL` needs it.
    pub fn setItemTooltip(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        if (self.isItemHovered(.{})) {
            self.setTooltip(fmt, args);
        }
    }

    /// Render `"label: <formatted-v>"` as a one-line read-only display.
    /// `v` can be any of:
    /// - `bool` → "true" / "false"
    /// - signed/unsigned int → decimal
    /// - float → "{d:.3}" (3 decimal places)
    /// - enum → `@tagName(v)`
    /// - `[]const u8` → the string bytes, passthrough
    /// - anything else → `{any}` fallback (ugly but readable)
    /// ImGui ships four overloads (`Value(prefix, bool)`,
    /// `Value(prefix, int)`, `Value(prefix, unsigned)`,
    /// `Value(prefix, float, format)`) because C++ doesn't have
    /// comptime dispatch. Zig does, so one signature covers all four
    /// imgui overloads plus enums and strings - net more capability,
    /// fewer functions. For a custom format (e.g. `"{d:.5}"`), reach
    /// for `Ui.text("{s}: {d:.5}",.{label, v})` directly; that's the
    /// same character count and zero new API.
    pub fn value(
        self: Ui,
        label: []const u8,
        v: anytype,
    ) void {
        valueImpl(self.ctx, label, v);
    }

    /// Toggleable boolean widget. Returns true if `*value` changed
    /// this frame (clicked while hovered). The function ALSO updates
    /// `*value` in place - caller can ignore the bool and just read
    /// `*value` after the call.
    pub fn checkbox(
        self: Ui,
        label: []const u8,
        v: *bool,
    ) bool {
        return checkboxImpl(self.ctx, label, v);
    }

    /// Drag-to-edit slider. Generic over numeric pointer types:
    /// `*f32`, `*f64`, `*i32`, `*u32`, `*i64`, `*u64`, etc. Returns
    /// true if the value changed this frame.
    ///     var f: f32 = 0.5;
    ///     _ = ui.slider("opacity", &f,.{.min = 0,.max = 1 });
    /// Comptime dispatch picks the right inner implementation
    /// float vs int - based on the pointed-to type.
    pub fn slider(
        self: Ui,
        label: []const u8,
        v: anytype,
        opts: SliderOpts,
    ) bool {
        return sliderDispatch(self.ctx, label, v, opts);
    }

    /// Inline RGB or RGBA editor. Generic over `*[3]f32`, `*[4]f32`,
    /// or `*Color` (zimr's u8 RGBA). Renders three or four slim
    /// sliders side-by-side plus a small swatch.
    pub fn colorEdit(
        self: Ui,
        label: []const u8,
        v: anytype,
        opts: ColorEditOpts,
    ) bool {
        return colorEditDispatch(self.ctx, label, v, opts);
    }

    /// Sparkline-style line plot. Takes a slice of `f32` samples;
    /// draws a connected line strip scaled to fit a row-height
    /// rectangle. Optional overlay text appears centered in the
    /// plot rect (imgui-style "current value" caption). When
    /// hovered, shows the value at the hovered index as a tooltip.
    /// Pattern (typical: a 60-frame FPS history):
    ///     _ = ui.plotLines("fps", &state.fps_hist,.{
    ///         .overlay = "60.0 fps",
    ///         .min = 0,
    ///         .max = 120,
    ///     });
    /// Pass `min == max` to auto-scale to the data's observed range.
    /// `height` is the plot rect's height; `0` means use a sensible
    /// default of 80px.
    pub fn plotLines(
        self: Ui,
        label: []const u8,
        values: []const f32,
        opts: PlotOpts,
    ) void {
        plotImpl(self.ctx, label, values, opts, .lines);
    }

    /// Bar-chart sparkline. Same API as `plotLines` but renders
    /// each sample as a filled column instead of connected lines.
    /// Suited to discrete-count data (histogram bins, per-second
    /// event counts, etc).
    pub fn plotHistogram(
        self: Ui,
        label: []const u8,
        values: []const f32,
        opts: PlotOpts,
    ) void {
        plotImpl(self.ctx, label, values, opts, .histogram);
    }

    /// Block-style color picker. Generic over `*[3]f32` (RGB),
    /// `*[4]f32` (RGBA), or `*Color` (zimr's u8 RGBA). Renders
    /// either a bar layout (hue gradient + SV box) or a wheel
    /// layout (hue ring + SV box inside) selectable via
    /// `opts.layout`. Returns true on any frame where the
    /// color changed.
    ///     var rgba: [4]f32 =.{ 0.2, 0.6, 0.9, 1.0 };
    ///     _ = ui.colorPicker("Tint", &rgba,.{
    ///         .layout =.wheel,
    ///         .alpha = true,
    ///     });
    pub fn colorPicker(
        self: Ui,
        label: []const u8,
        v: anytype,
        opts: ColorPickerOpts,
    ) bool {
        return colorPickerDispatch(self.ctx, label, v, opts);
    }

    /// Drop the cursor down by one row's worth - useful between two
    /// `sameLine`-prefixed widgets when you want to break the row
    /// without inserting a separator.
    pub fn newLine(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        const consumed: Vec2 = .{ 0, self.ctx.style.font_size };
        advanceLayout(w, consumed, self.ctx.style.item_spacing, self.ctx.style.window_padding[0]);
    }

    /// Drag-to-edit numeric widget. Like `slider` but unbounded
    /// each pixel of mouse drag changes the value by `opts.speed`,
    /// hard-clamped only if `opts.min`/`opts.max` are set. Common
    /// uses: world coordinates, infinite-range tunables.
    ///     var speed: f32 = 5.0;
    ///     _ = ui.drag("Speed", &speed,.{.speed = 0.1 });
    pub fn drag(
        self: Ui,
        label: []const u8,
        v: anytype,
        opts: DragOpts,
    ) bool {
        return dragDispatch(self.ctx, label, v, opts);
    }

    /// Mutually-exclusive selection: a small circle + label. Returns
    /// true if `*current` changed. Pattern (matches ImGui's
    /// per-button overload):
    ///     _ = ui.radioButton("Easy", &state.diff, 0);
    ///     _ = ui.radioButton("Hard", &state.diff, 1);
    pub fn radioButton(
        self: Ui,
        label: []const u8,
        current: *i32,
        v: i32,
    ) bool {
        return radioButtonImpl(self.ctx, label, current, v);
    }

    /// List-item-style clickable text. Returns true on the click
    /// frame. When `selected` is true the row paints with the
    /// active highlight. Foundation for menu items, file pickers,
    /// list selectors.
    pub fn selectable(
        self: Ui,
        label: []const u8,
        selected: bool,
        opts: SelectableOpts,
    ) bool {
        return selectableImpl(self.ctx, label, selected, opts);
    }

    /// Draggable splitter bar between two adjacent panes. Returns
    /// `true` while the user is dragging (held).
    /// `split_pos` is the size of the FIRST pane (left for `.x`,
    /// top for `.y`). The widget mutates it on drag, clamped by
    /// `opts.min1` and `opts.min2` (against `total - min2 -
    /// bar_width`).
    /// Canonical usage with `beginChild`:
    ///     const total_w = u.getContentRegionAvail()[0];
    ///     const total_h = u.getContentRegionAvail()[1];
    ///     const right_w = total_w - state.left_w - 4;
    ///     if (u.beginChild("L",.{ state.left_w, total_h },.{})) {
    ///         defer u.endChild();
    ///         //... left content
    ///     }
    ///     u.sameLine(.{});
    ///     _ = u.splitter("split", &state.left_w,.x,.{
    ///         .min1 = 80,.min2 = 100,.total_along_axis = total_w,
    ///     });
    ///     u.sameLine(.{});
    ///     if (u.beginChild("R",.{ right_w, total_h },.{})) {
    ///         defer u.endChild();
    ///         //... right content
    ///     }
    pub fn splitter(
        self: Ui,
        str_id: []const u8,
        split_pos: *f32,
        axis: SplitterAxis,
        opts: SplitterOpts,
    ) bool {
        return splitterImpl(self.ctx, str_id, split_pos, axis, opts);
    }

    // ---- MultiSelect API
    // See `src/notes/multiselect-tutorial.md` for the conceptual
    // walkthrough. Canonical caller pattern:
    //     const io = ui.beginMultiSelect(.{.clear_on_escape = true },
    //                                    @intCast(sel.size()), items.len);
    //     try sel.applyRequests(gpa, io); // drain pending
    //     for (items, 0..) |item, n| {
    //         ui.setNextItemSelectionUserData(@intCast(n));
    //         _ = ui.selectable(item.label, sel.contains(n),.{});
    //     }
    //     const io2 = ui.endMultiSelect();
    //     try sel.applyRequests(gpa, io2); // drain emitted

    /// Open a multi-select scope. Returns the IO view with any
    /// pending requests (from this frame's keyboard shortcuts or
    /// previous frame's box-select completion) ready to apply.
    /// `selection_size` and `items_count` are informational
    /// passed back via `io.items_count` for `applyRequests` to
    /// scope SetAll over. Pass `items_count = items.len` and
    /// `selection_size = sel.size()` from your storage.
    /// returns IO with empty requests + null anchor /
    /// nav_id. Real Begin-time logic (apply keyboard shortcuts,
    /// detect scope focus) lands.
    pub fn beginMultiSelect(
        self: Ui,
        flags: MultiSelectFlags,
        selection_size: u32,
        items_count: u32,
    ) *const MultiSelectIO {
        return beginMultiSelectImpl(self.ctx, flags, selection_size, items_count);
    }

    /// Close the multi-select scope and return the IO view with
    /// this loop's click-emitted requests. Call `applyRequests`
    /// on it to update storage.
    pub fn endMultiSelect(self: Ui) *const MultiSelectIO {
        return endMultiSelectImpl(self.ctx);
    }

    /// Tag the next widget with a selection-user-data value. In a
    /// MultiSelect scope, the widget's item-side hooks read this
    /// to identify the item for range-select bookkeeping and
    /// SetRange request emission. Outside a scope, the value is
    /// still stored (matches imgui - widgets pre-tag cheaply) but
    /// nothing consumes it.
    pub fn setNextItemSelectionUserData(self: Ui, data: u64) void {
        self.ctx.next_item_selection_user_data = data;
    }

    /// Click-to-toggle section header. When `*open` is true the
    /// caller should submit the section's child widgets; when
    /// false they're suppressed (caller is expected to gate them
    /// with `if`). Returns true when the section is open this
    /// frame (== `*open`). Pattern:
    ///     if (ui.collapsingHeader("Advanced", &state.show_adv)) {
    ///         _ = ui.checkbox("Verbose logging", &state.verbose);
    ///         _ = ui.slider("Buffer size", &state.bufsz,.{.max = 1024 });
    ///     }
    pub fn collapsingHeader(
        self: Ui,
        label: []const u8,
        open: *bool,
    ) bool {
        return collapsingHeaderImpl(self.ctx, label, open);
    }

    // ---- tables - layout core
    /// Open a table scope. `n_columns` is the column count (each
    /// column gets `tableSetupColumn` between begin and the first
    /// row, or sensible defaults are used). Returns true when the
    /// table is active (always today; reserved for future
    /// auto-hide flags). Pattern:
    ///     if (ui.beginTable("scores", 3,.{.borders = true })) {
    ///         ui.tableSetupColumn("Name",.{.sizing =.stretch });
    ///         ui.tableSetupColumn("Score",.{.sizing =.fixed,.width = 80 });
    ///         ui.tableSetupColumn("Rank",.{.sizing =.fixed,.width = 50 });
    ///         for (rows) |r| {
    ///             ui.tableNextRow();
    ///             ui.tableNextColumn();
    ///             ui.text("{s}",.{r.name});
    ///             ui.tableNextColumn();
    ///             ui.text("{d}",.{r.score});
    ///             ui.tableNextColumn();
    ///             ui.text("#{d}",.{r.rank});
    ///         }
    ///         ui.endTable();
    ///     }
    pub fn beginTable(
        self: Ui,
        label: []const u8,
        n_columns: u32,
        opts: TableOpts,
    ) bool {
        return beginTableImpl(self.ctx, label, n_columns, opts);
    }

    /// Close a table scope. Must match a `beginTable` that
    /// returned true.
    pub fn endTable(self: Ui) void {
        endTableImpl(self.ctx);
    }

    /// Configure one column. Call between `beginTable` and the
    /// first `tableNextRow`; subsequent calls during data rows
    /// are no-ops. Order is left-to-right.
    pub fn tableSetupColumn(
        self: Ui,
        label: []const u8,
        opts: TableColumnOpts,
    ) void {
        tableSetupColumnImpl(self.ctx, label, opts);
    }

    /// Advance to the next data row. Resets the current column
    /// index to 0 and computes the row's y position based on the
    /// previous row's height.
    pub fn tableNextRow(self: Ui) void {
        tableNextRowImpl(self.ctx);
    }

    /// Advance to the next column within the current row. Sets
    /// the layout cursor to the column's x position; the next
    /// widget renders inside that column's clip rect. Returns
    /// true when the column is "in bounds" (i.e. we haven't run
    /// past `n_columns` for this row).
    pub fn tableNextColumn(self: Ui) bool {
        return tableNextColumnImpl(self.ctx);
    }

    /// Render the table's header row. Call BEFORE the first
    /// `tableNextRow`. Each column gets a clickable header that
    /// cycles sort state when clicked. Shift-click appends as
    /// a secondary key (up to N=3 per decision Q8). Plain click
    /// either flips the direction (if already primary) or
    /// promotes that column to the new primary.
    /// Pattern:
    ///     u.tableHeadersRow();
    ///     if (u.tableGetSortSpecs()) |specs| {
    ///         std.mem.sort(Row, &rows, specs, sortCmp);
    ///     }
    ///     for (rows) |r| {
    ///         u.tableNextRow();
    ///         ...
    ///     }
    pub fn tableHeadersRow(self: Ui) void {
        tableHeadersRowImpl(self.ctx);
    }

    /// Return the active table's current sort spec, or null if no
    /// sort is configured (no columns clicked yet). Pointer is
    /// stable for the rest of the current frame. Read between
    /// `tableHeadersRow` and the first `tableNextRow` to sort
    /// the data before submitting it.
    pub fn tableGetSortSpecs(self: Ui) ?*const TableSortSpecs {
        return tableGetSortSpecsImpl(self.ctx);
    }

    /// Index of the column currently being populated (0-based).
    /// Null if no table is active, or if no `tableNextColumn`
    /// has run yet this row (i.e. between `tableNextRow` and
    /// the first `tableNextColumn`).  Useful for per-cell
    /// branching when the loop body is shared across columns
    /// but renders slightly different content per column.
    pub fn tableGetColumnIndex(self: Ui) ?u32 {
        const ts: *const TableState = if (self.ctx.active_table) |*t| t else return null;
        if (ts.cur_col < 0) {
            return null;
        }
        return @intCast(ts.cur_col);
    }

    /// Number of columns configured at `beginTable`.  Null if no
    /// table is active.  Stable across the entire frame; reads
    /// the count the caller passed to `beginTable`, not the
    /// number of `tableSetupColumn` calls made so far.
    pub fn tableGetColumnCount(self: Ui) ?u32 {
        const ts: *const TableState = if (self.ctx.active_table) |*t| t else return null;
        return ts.n_columns;
    }

    /// Display name of column `index` as configured at
    /// `tableSetupColumn`, or null if no active table or if
    /// `index >= column_count`.  The returned slice points into
    /// the column's inline `name_buf` and is stable across the
    /// current frame.  Columns whose `tableSetupColumn` call
    /// passed an empty label return an empty slice (not null).
    pub fn tableGetColumnName(self: Ui, index: u32) ?[]const u8 {
        const ts: *const TableState = if (self.ctx.active_table) |*t| t else return null;
        if (index >= ts.n_columns) {
            return null;
        }
        return ts.columns[index].name();
    }

    /// Snapshot of column `index`'s queryable flag state.  Null
    /// if no active table or `index >= column_count`.  See
    /// `TableColumnFlags` docs re: what's included vs why some
    /// `TableColumnOpts` fields (sizing/width/weight) aren't
    /// roundtrippable through column state.
    pub fn tableGetColumnFlags(self: Ui, index: u32) ?TableColumnFlags {
        const ts: *const TableState = if (self.ctx.active_table) |*t| t else return null;
        if (index >= ts.n_columns) {
            return null;
        }
        const c: *const TableColumnState = &ts.columns[index];
        return .{
            .no_sort = c.no_sort,
            .no_sort_ascending = c.no_sort_ascending,
            .no_sort_descending = c.no_sort_descending,
            .no_header_label = c.no_header_label,
            .default_sort = c.default_sort,
        };
    }

    /// Override the current row's background color, overriding
    /// any alternating tint that `TableOpts.row_bg` would
    /// otherwise apply. Call AFTER `tableNextRow` and BEFORE
    /// `tableNextColumn` to set the bg for the row about to be
    /// populated.
    /// Use cases: highlight a selected row, flag a warning row in
    /// red, mark a "current user" row in accent, etc. Pass
    /// `Color.transparent` (alpha=0) to force NO background even
    /// when alternating tint would otherwise paint one.
    pub fn tableSetRowBgColor(self: Ui, color: Color) void {
        if (self.ctx.active_table) |*ts| {
            ts.row_bg_override = color;
        }
    }

    /// Override the background color of the CURRENT cell — the one
    /// just entered via `tableNextColumn`. Call AFTER
    /// `tableNextColumn` for the column you want to tint.
    /// Use cases: heat-map cells, flag specific values, draw
    /// attention to a single field without highlighting the whole
    /// row. The cell bg paints UNDER the cell's content (P7.2's
    /// 2-channel render) and OVER the row bg (cell-level overrides
    /// are more specific than row-level — so they win visually).
    /// No-op when called outside a table or before any
    /// `tableNextColumn`. Pass `Color.transparent` to clear a
    /// pending override that hasn't been painted yet.
    /// ImGui equivalent: `TableSetBgColor(ImGuiTableBgTarget_CellBg,
    /// color)` with the implicit "current column" target.
    pub fn tableSetCellBgColor(self: Ui, color: Color) void {
        const ts: *TableState = if (self.ctx.active_table) |*t| t else return;
        if (ts.cur_col < 0) {
            return;
        }
        const col_idx: usize = @intCast(ts.cur_col);
        if (col_idx >= ts.n_columns) {
            return;
        }
        ts.columns[col_idx].cell_bg_pending = color;
    }

    // ---- drag-drop source
    /// Open a drag-drop source scope. Call IMMEDIATELY after a
    /// widget that should be draggable - the widget's last_item_id
    /// is captured as the drag origin. Returns true when a drag
    /// is actively in flight from this widget THIS FRAME, in which
    /// case the caller should:
    ///   1. Call `setDragDropPayload` to register a typed payload.
    ///   2. Submit any preview widgets (text, icon) that should
    ///      follow the cursor.
    ///   3. Call `endDragDropSource` to close the scope.
    ///     if (ui.button("drag me")) {}
    ///     if (ui.beginDragDropSource(.{})) {
    ///         ui.setDragDropPayload(BuildId, &build.id);
    ///         ui.text("Build #{d}",.{build.id});
    ///         ui.endDragDropSource();
    ///     }
    /// Drag begins on mouse-down + movement past
    /// `DragDropState.DRAG_THRESHOLD` pixels (matches imgui's 4 px
    /// default). Drag ends on mouse-up; if no target consumed
    /// the payload, it's simply discarded.
    pub fn beginDragDropSource(self: Ui, opts: DragDropFlags) bool {
        return beginDragDropSourceImpl(self.ctx, opts);
    }

    /// Register a typed payload for the in-flight drag. Generic
    /// over `T` - the type tag is `@typeName(T)`, so the target
    /// side can match types at compile time. `value` is a
    /// pointer; the bytes are copied into the drag state's inline
    /// buffer. Must be called inside a `beginDragDropSource`
    /// scope that returned true.
    /// Payload size cap: `DragDropState.PAYLOAD_CAP` bytes.
    /// Oversize values silently fail to register - the source
    /// scope still renders preview, but no target will accept.
    pub fn setDragDropPayload(
        self: Ui,
        comptime T: type,
        v: *const T,
    ) void {
        setDragDropPayloadImpl(self.ctx, T, v);
    }

    /// Close a drag-drop source scope. Pairs with a
    /// `beginDragDropSource` that returned true.
    pub fn endDragDropSource(self: Ui) void {
        endDragDropSourceImpl(self.ctx);
    }

    /// Open a drop-target scope. Call IMMEDIATELY after a widget
    /// that should accept drops. Returns true when:
    ///   1. A drag is currently active, AND
    ///   2. The cursor is over the just-submitted widget's rect.
    /// While the target scope is open the target's rect is
    /// outlined in `style.button_active` so the user sees where
    /// the drop will land. Pattern:
    ///     // a slot/lane widget
    ///     ui.child("lane:" ++ name,.{ 200, 80 });
    ///     if (ui.beginDragDropTarget(.{})) {
    ///         if (ui.acceptDragDropPayload(BuildPayload,.{})) |build| {
    ///             // user released over this lane → move build here
    ///         }
    ///         ui.endDragDropTarget();
    ///     }
    /// `opts: DragDropFlags` - pass `.{}` for default. Useful
    /// flags: `.accept_no_draw_default_rect` (suppress the dashed
    /// highlight rect), `.accept_draw_as_hovered` (render the
    /// target as if hovered).
    pub fn beginDragDropTarget(self: Ui, opts: DragDropFlags) bool {
        return beginDragDropTargetImpl(self.ctx, opts);
    }

    /// Try to take the active drag's payload as a `T`. Returns the
    /// payload value when:
    ///   - The drag is active.
    ///   - The current widget is the active drop target (i.e.
    ///     called inside a `beginDragDropTarget` that returned
    ///     true).
    ///   - The payload's type tag matches `@typeName(T)`.
    ///   - The user RELEASED the mouse this frame (release-edge),
    ///     unless `opts.accept_before_delivery` is set, in which
    ///     case mid-drag peeks succeed too.
    /// Returns null otherwise. After accepting, the payload is
    /// still readable for the rest of this frame; cleanup happens
    /// at endFrame.
    pub fn acceptDragDropPayload(
        self: Ui,
        comptime T: type,
        opts: DragDropFlags,
    ) ?T {
        return acceptDragDropPayloadImpl(self.ctx, T, opts);
    }

    /// Close a drop-target scope. Pairs with a
    /// `beginDragDropTarget` that returned true.
    pub fn endDragDropTarget(self: Ui) void {
        self.ctx.drag_drop.target_widget_id = 0;
    }

    /// Tiny filled square + same-line cursor advance, ImGui-style.
    /// Common as a list-item glyph when you want a manually-bulleted
    /// paragraph.
    pub fn bullet(self: Ui) void {
        bulletImpl(self.ctx);
    }

    /// Bullet glyph + formatted text on the same line. Equivalent
    /// to `ui.bullet(); ui.sameLine(.{}); ui.text(fmt, args);`.
    pub fn bulletText(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        bulletImpl(self.ctx);
        self.sameLine(.{});
        textImpl(self.ctx, fmt, args, self.ctx.style.text);
    }

    /// Display a GPU texture inline at the current cursor. Accepts
    /// any of:
    /// - `u32` / `u32` - raw GL texture ID (legacy / advanced)
    /// - `Texture` / `*const Texture` - raylib-native, infers size
    /// - `RenderTexture` / `*const RenderTexture` - render target,
    ///   infers size AND auto-flips V coordinates so the user sees
    ///   it the right way up (RTs in OpenGL are origin-bottom-left)
    /// Pass `null` for `size` to use the texture's natural pixel size,
    /// or a `Vec2` to scale. No interaction - pair with `selectable`
    /// or `button` for clickable image variants.
    /// This is the bridge that makes recursive UI-on-render-texture
    /// composition feel natural: render a HUD to an RT, then
    /// `f.ui.image(my_rt, null)` displays it elsewhere.
    pub fn image(
        self: Ui,
        source: anytype,
        size: ?Vec2,
    ) void {
        const T = @TypeOf(source);
        var tex_id: u32 = 0;
        var natural: Vec2 = .{ 64, 64 };
        var flipped: bool = false;

        switch (@typeInfo(T)) {
            .int, .comptime_int => {
                tex_id = @intCast(source);
            },
            .pointer => |p| {
                // Single-item pointer to Texture / RenderTexture.
                switch (p.child) {
                    types.Texture => {
                        tex_id = source.id;
                        natural = .{ @floatFromInt(source.width), @floatFromInt(source.height) };
                    },
                    RenderTexture => {
                        tex_id = source.texture.id;
                        natural = .{ @floatFromInt(source.texture.width), @floatFromInt(source.texture.height) };
                        flipped = true;
                    },
                    else => @compileError(
                        "ui.image: pointer must target Texture or RenderTexture, got *" ++ @typeName(p.child),
                    ),
                }
            },
            .@"struct" => switch (T) {
                types.Texture => {
                    tex_id = source.id;
                    natural = .{ @floatFromInt(source.width), @floatFromInt(source.height) };
                },
                RenderTexture => {
                    tex_id = source.texture.id;
                    natural = .{ @floatFromInt(source.texture.width), @floatFromInt(source.texture.height) };
                    flipped = true;
                },
                else => @compileError("ui.image: unsupported struct type " ++ @typeName(T)),
            },
            else => @compileError("ui.image: pass u32 GL ID, Texture, or RenderTexture, got " ++ @typeName(T)),
        }

        imageImpl(self.ctx, tex_id, size orelse natural, flipped);
    }

    /// Direct mutable access to the active style (Tier A - see
    /// `docs/raylib-ui-integration.md`). Lets users tweak colors
    /// without reaching into internal `ctx`. Pattern:
    ///     f.ui.style().button = z.colors.amber_500;
    pub fn style(self: Ui) *Style {
        return &self.ctx.style;
    }

    /// Temporarily override one field of `Style` until the matching
    /// `popStyle()`. Reflection-based: `field_name` is a comptime
    /// string that must name an actual field of `Style`; `value`
    /// is typed via `anytype` and must coerce to that field's type
    /// (compile error otherwise).
    ///     ui.pushStyle("frame_padding",.{ 12, 8 });
    ///     defer ui.popStyle();
    ///     _ = ui.button("Chunky",.{});
    /// Stack-cap: `style_stack`'s 32 slots. Every push must be
    /// paired with a pop; mismatched pushes/pops leave the style
    /// in a wrong state for the rest of the frame.
    pub fn pushStyle(
        self: Ui,
        comptime field_name: []const u8,
        v: anytype,
    ) void {
        pushStyleImpl(self.ctx, field_name, v);
    }

    /// Pair with `pushStyle`. Restores the most-recently-pushed
    /// field to its prior value. Safe to call on an empty stack
    /// (no-op).
    pub fn popStyle(self: Ui) void {
        popStyleImpl(self.ctx);
    }

    /// Push a font onto the style stack. Subsequent widgets render
    /// with `*font` until the matching `popFont()`. Use for code
    /// sections (mono font), section headers (larger / different
    /// face), etc.
    /// Currently `font_size` is NOT pushed alongside - fonts in zimr
    /// carry their own design size separately from the rendered size.
    /// To change rendered size while pushed, call `pushStyle(
    /// "font_size", new_size)` separately. ImGui's `PushFont(font,
    /// size)` does both; zimr keeps them separable so a single font
    /// can be scaled by callers without re-pushing.
    /// Convenience wrapper over `pushStyle("font", font)`. Pairs
    /// with `popFont()`. Pushing a null pointer reverts to the
    /// default cached font. ImGui: `PushFont(font)`.
    pub fn pushFont(self: Ui, font: ?*const Font) void {
        pushStyleImpl(self.ctx, "font", font);
    }

    /// Pair with `pushFont`. Restores the most-recently-pushed
    /// font. Safe to call on an empty stack (no-op). ImGui:
    /// `PopFont()`.
    pub fn popFont(self: Ui) void {
        popStyleImpl(self.ctx);
    }

    /// Read the current layout cursor (next-widget position) for
    /// the active window. Useful before calling `setCursorPos` to
    /// stash + restore manual layout decisions, or to overlay
    /// custom rendering at a known point in the flow. Returns
    /// `(0, 0)` outside any window scope.
    pub fn getCursorPos(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return w.layout.cursor_pos;
    }

    /// Force the layout cursor to a specific position within the
    /// current window. Use case: manually placing a widget at a
    /// pixel-precise spot, or restoring after a `getCursorPos`
    /// snapshot. No-op outside any window scope.
    pub fn setCursorPos(self: Ui, pos: Vec2) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.layout.cursor_pos = pos;
        w.layout.line_height = 0;
    }

    /// Returns true if the UI currently wants to consume keyboard
    /// input - i.e., an `inputText` is focused. Game-input handlers
    /// should gate themselves on `!f.ui.wantCaptureKeyboard()` to
    /// avoid double-firing while the user types into a UI field.
    pub fn wantCaptureKeyboard(self: Ui) bool {
        // Active widget plus an input_text_state cursor implies a
        // text field has focus. The state's defaults map to "no
        // focus" so this is robust across activation sources.
        return self.ctx.active_id != 0;
    }

    /// Returns true if the UI currently wants to consume mouse input
    /// - i.e., the pointer is over a UI window or a UI widget is
    /// being interacted with. Game-input handlers (camera drag,
    /// world-click selection) should gate themselves on
    /// `!f.ui.wantCaptureMouse()` to avoid clicking through windows.
    pub fn wantCaptureMouse(self: Ui) bool {
        if (self.ctx.active_id != 0) {
            return true;
        }
        // Hit-test the mouse against every window submitted this
        // frame. Cheap (typically ≤ 8 windows; bounded stack) and
        // robust to overlapping windows.
        const m: Vec2 = self.ctx.input.mouse_pos;
        for (self.ctx.frame_windows.items[0..self.ctx.frame_windows.len]) |w| {
            const r = Rectangle{ .x = w.pos[0], .y = w.pos[1], .width = w.size[0], .height = w.size[1] };
            if (pointInRect(m, r)) {
                return true;
            }
        }
        // ...and against LAST frame's windows, so this answers correctly even
        // when called before this frame's UI has been submitted (the natural
        // order: gate scene input first, draw the UI after). Without this the
        // list above is still empty and the pointer clicks straight through the
        // panel. Matches Dear ImGui, whose WantCaptureMouse likewise reflects
        // the previous frame's layout.
        for (self.ctx.prev_window_rects.items[0..self.ctx.prev_window_rects.len]) |r| {
            if (pointInRect(m, r)) {
                return true;
            }
        }
        return false;
    }

    /// Get the draw list of the currently-active window so the caller
    /// can record arbitrary `addLine` / `addCircleFilled` / etc.
    /// primitives inside it. Returns null when called outside a
    /// `ui.window(...)` block. The returned pointer is valid only
    /// for the rest of the current frame.
    pub fn getDrawList(self: Ui) ?*DrawList {
        const w: *Window = self.ctx.current_window orelse return null;
        return &w.draw_list;
    }

    /// Allocator backing the DrawList's command list + variable
    /// payloads. Returns the UiContext's frame arena, which is
    /// reset at endFrame - so any slice you allocate through this
    /// is valid for exactly one frame. Demos that call
    /// `getDrawList().addPolyline(...)` pass the result here as the
    /// `alloc` argument.
    pub fn drawListAllocator(self: Ui) Allocator {
        return self.ctx.frame_arena.allocator();
    }

    /// Current layout cursor position in screen-space pixels. Useful
    /// when you want to place custom-rendered primitives at the spot
    /// the next widget would land. Returns `.{0,0}` when called
    /// outside a window block.
    pub fn getCursorScreenPos(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return w.layout.cursor_pos;
    }

    /// Cursor position at the start of the current window's content
    /// area, in screen-space pixels. Useful as a reference point
    /// for computing relative offsets within a window — e.g.
    /// `getCursorPos() - getCursorStartPos()` is how far down the
    /// content area you've laid out so far. Returns `.{0,0}` when
    /// called outside a window block. Differs from raw ImGui
    /// `GetCursorStartPos()` which returns window-local coords; the
    /// rest of zimr's cursor accessors use screen-space, so this
    /// getter follows that convention. ImGui: `GetCursorStartPos()`.
    pub fn getCursorStartPos(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return w.layout.origin;
    }

    /// Single-line text edit. Caller passes the backing buffer
    /// (`buf: []u8`) and a pointer to its current content length
    /// (`len: *usize`). We grow `len` up to `buf.len` on type, never
    /// realloc. Returns true on any frame where contents changed.
    /// Pattern:
    ///     var name_buf: [64]u8 = undefined;
    ///     var name_len: usize = 0;
    ///     _ = ui.inputText("Name", &name_buf, &name_len,.{});
    /// Supports: insert at cursor, backspace, delete, left/right
    /// arrows, home/end, click-to-position cursor, commit-on-Enter
    /// (defocuses), cancel-on-Escape (defocuses, no revert). No
    /// selection ranges or clipboard support.
    pub fn inputText(
        self: Ui,
        label: []const u8,
        buf: []u8,
        len: *usize,
        opts: InputTextOpts,
    ) bool {
        return inputTextImpl(self.ctx, label, buf, len, opts);
    }

    /// Like `inputText`, but `hint` is shown in dimmed
    /// `style.text_disabled` color when the buffer is empty AND
    /// the widget isn't focused. Disappears the moment the user
    /// types or focuses. Equivalent to setting `opts.hint`
    /// directly - provided for ImGui parity (`InputTextWithHint`).
    ///     _ = ui.inputTextWithHint("Search", "type to search...",
    ///                              &state.query_buf, &state.query_len,.{});
    pub fn inputTextWithHint(
        self: Ui,
        label: []const u8,
        hint: []const u8,
        buf: []u8,
        len: *usize,
        opts: InputTextOpts,
    ) bool {
        var o: @TypeOf(opts) = opts;
        o.hint = hint;
        return inputTextImpl(self.ctx, label, buf, len, o);
    }

    /// Multi-line text editor. Same buffer-and-length contract as
    /// `inputText`, plus an explicit `size: Vec2` for the
    /// editor's rendered dimensions (in pixels - caller decides;
    /// `size.x <= 0` auto-fills the remaining row width). Returns
    /// true on any frame the buffer changed.
    /// Differences from `inputText`:
    ///   - **Enter inserts a newline** (single-line variant
    ///     defocuses on Enter).
    ///   - **Up / Down arrows** move the cursor between lines,
    ///     preserving column where possible.
    ///   - **Click-to-place** computes the target line by y-
    ///     distance from the editor's top, then the column by x-
    ///     distance into that line.
    ///   - **Escape defocuses** without committing.
    /// Limitations (deferred to later phases):
    ///   - No selection (shift-arrow, mouse-drag).
    ///   - No clipboard cut/copy/paste - lands in Phase A4 with
    ///     `InputTextCallbackData`.
    ///   - No undo/redo.
    ///   - No tab character handling (Tab is consumed as a
    ///     focus-traversal key by the runtime; pasting a string
    ///     with `\t` works, typing one doesn't).
    ///   - No scroll - caller wraps in `beginChild(...)` for
    ///     scrollable editing of buffers taller than `size.y`.
    pub fn inputTextMultiline(
        self: Ui,
        label: []const u8,
        buf: []u8,
        len: *usize,
        size: Vec2,
        opts: InputTextOpts,
    ) bool {
        return inputTextMultilineImpl(self.ctx, label, buf, len, size, opts);
    }

    /// Slider over an angle stored as RADIANS, displayed and
    /// dragged in DEGREES. ImGui's `SliderAngle` convention: the
    /// math wants radians, the user wants degrees. Returns true
    /// when the value changed this frame. `min_deg` / `max_deg`
    /// are in degrees (default -360 / 360).
    ///     _ = ui.sliderAngle("Rotation", &state.yaw_rad,.{});
    pub fn sliderAngle(
        self: Ui,
        label: []const u8,
        rad: *f32,
        opts: SliderAngleOpts,
    ) bool {
        var deg: f32 = degFromRad(rad.*);
        const changed: bool = self.slider(label, &deg, .{
            .min = opts.min_deg,
            .max = opts.max_deg,
            .fmt = opts.fmt,
        });
        if (changed) {
            rad.* = radFromDeg(deg);
        }
        return changed;
    }

    /// Shorthand for `if (isItemHovered()) beginTooltip()`. Open a
    /// tooltip scope ONLY when the just-submitted widget is
    /// currently hovered. Returns true when the tooltip is open
    /// - caller submits tooltip content then calls
    /// `endItemTooltip()`.
    ///     _ = ui.button("?",.{});
    ///     if (ui.beginItemTooltip()) {
    ///         defer ui.endItemTooltip();
    ///         ui.text("This button does the thing.",.{});
    ///         ui.text("(Press Enter to confirm)",.{});
    ///     }
    pub fn beginItemTooltip(self: Ui) bool {
        if (!self.isItemHovered(.{})) {
            return false;
        }
        self.beginTooltip();
        return true;
    }

    /// Pair with `beginItemTooltip` when it returned true.
    /// Identical to `endTooltip` - separate name keeps the
    /// matched pair visually obvious in caller code.
    pub fn endItemTooltip(self: Ui) void {
        self.endTooltip();
    }

    /// Public wrapper over the internal text-measurement
    /// machinery. Use to lay out custom widgets that need to
    /// reserve space for a piece of text without first drawing
    /// it. Honors the current `Style.font` + `font_size` +
    /// `font_spacing`.
    pub fn calcTextSize(self: Ui, s: []const u8) Vec2 {
        return measureTextS(self.ctx, s);
    }

    /// Queue keyboard focus for the NEXT focusable widget
    /// submitted this frame. Call BEFORE the widget you want
    /// focused (e.g. before an `inputText` you want to be ready
    /// for typing the moment a modal opens). No-op if no
    /// focusable widget follows in the same frame. Today only
    /// `inputText` honors the flag.
    ///     if (state.search_just_opened) {
    ///         ui.setKeyboardFocusHere();
    ///         state.search_just_opened = false;
    ///     }
    ///     _ = ui.inputText("Search", &buf, &len,.{});
    pub fn setKeyboardFocusHere(self: Ui) void {
        self.ctx.next_widget_take_focus = true;
    }

    /// Dropdown picker - click the closed row to expand a vertical
    /// list of items, click an item to select. Returns true on the
    /// frame the selection changed.
    ///     const modes = [_][]const u8{ "Off", "Low", "Medium", "High" };
    ///     _ = ui.combo("Quality", &state.quality_idx, &modes,.{});
    /// Single-slot: only one combo can be open at a time. Clicking
    /// outside the dropdown (or on the closed row again) closes it.
    /// **Real overlay popup** - the dropdown is recorded into the
    /// foreground draw list, replayed at endFrame on top of all
    /// windows. The layout below the combo does NOT reflow when
    /// the dropdown opens; the dropdown floats over whatever's there.
    /// This matches Dear ImGui's combo behavior. Multiple combos in
    /// the same window are stable: open one, the layout below stays
    /// put.
    pub fn combo(
        self: Ui,
        label: []const u8,
        current_index: *i32,
        items: []const []const u8,
        opts: ComboOpts,
    ) bool {
        return comboImpl(self.ctx, label, current_index, items, opts);
    }

    /// Begin a combo dropdown that the caller fills with custom
    /// content. Sibling of `combo()` (which takes a string array);
    /// use `beginCombo` when rows need icons, color swatches,
    /// descriptions, or any other widget. Returns true when the
    /// dropdown is open - caller submits widgets, then MUST call
    /// `endCombo()` to close the scope. Returns false when
    /// closed; do NOT call endCombo in that case. ImGui:
    /// `BeginCombo(label, preview_value, flags)`.
    pub fn beginCombo(
        self: Ui,
        label: []const u8,
        preview: []const u8,
        opts: ComboOpts,
    ) bool {
        return beginComboImpl(self.ctx, label, preview, opts);
    }

    /// Close a `beginCombo` scope. Pair with EXACTLY one
    /// `beginCombo` that returned true. ImGui: `EndCombo()`.
    pub fn endCombo(self: Ui) void {
        endComboImpl(self.ctx);
    }

    /// Recursive disclosure-triangle hierarchy node. Library-managed
    /// open state (persistent across frames in `UiContext.tree_open_state`).
    /// Returns true when open - the caller should submit child widgets
    /// inside the `if` block and pair with `treePop()` on the way out.
    /// Pattern:
    ///     if (ui.treeNode("Player",.{})) {
    ///         defer ui.treePop();
    ///         _ = ui.slider("HP", &state.hp,.{.min = 0,.max = 100 });
    ///         _ = ui.slider("MP", &state.mp,.{.min = 0,.max = 100 });
    ///         if (ui.treeNode("Inventory",.{})) {
    ///             defer ui.treePop();
    ///             _ = ui.checkbox("Sword", &state.has_sword);
    ///         }
    ///     }
    /// Each open node bumps the window's `indent_x` so children
    /// visually nest. Nodes are stable across frames (open state
    /// keyed by hashed label + parent ID).
    /// Opts-arg form: pass `.{}` for the
    /// default click-toggle node, or set fields on `TreeNodeOpts`
    /// for flags like `.default_open`, `.leaf`, `.framed`, etc.
    /// See `TreeNodeOpts` for the full flag inventory.
    ///     if (ui.treeNode("src",.{.default_open = true })) {
    ///         defer ui.treePop();
    ///         _ = ui.treeNode("ui.zig",.{.leaf = true });
    ///         ui.treePop(); // pair with leaf
    ///     }
    /// For `opts.leaf = true`, always returns true (no toggle, no
    /// arrow - caller still calls `treePop` if they push children,
    /// otherwise can skip the pop). Replaces the old
    /// `treeNode(label)` + `treeNodeEx(label, opts)` dual API.
    pub fn treeNode(
        self: Ui,
        label: []const u8,
        opts: TreeNodeOpts,
    ) bool {
        return treeNodeExImpl(self.ctx, label, opts);
    }

    /// Pair with `treeNode` on the way out. Decrements indent.
    /// Safe to call when the tree was closed (no-op).
    /// In the new layout model, this just decrements
    /// `indent_x` - the next widget's `advanceLayout` will reset
    /// the cursor X to `origin + indent` from the popped depth.
    /// No need to manually mutate `cursor_pos` here (the turn-308
    /// hack of "also decrement cursor_pos[0]" is no longer needed,
    /// because the cursor is recomputed from origin+indent on
    /// every row).
    pub fn treePop(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        if (w.layout.indent_x >= indent_amount) {
            w.layout.indent_x -= indent_amount;
            // Update cursor_pos[0] for the first widget submitted
            // right after the pop (before any advanceLayout has
            // re-resolved it). Without this, that widget would see
            // a stale cursor_pos[0] from the deeper-indent inner
            // scope and render one indent_amount too far right.
            // Imgui-style screen-X formula: origin - scroll + local.
            w.layout.cursor_pos[0] = w.layout.origin[0] - w.scroll_x + w.layout.indent_x;
        }
    }

    /// Force the next tree node's open state. Cleared after
    /// consumption. Imgui: `SetNextItemOpen`.
    pub fn setNextItemOpen(self: Ui, open: bool) void {
        self.ctx.next_item_open = open;
    }

    /// True if the most-recently-submitted tree node was toggled
    /// this frame. Imgui: `IsItemToggledOpen()` (queries the
    /// ToggledOpen status flag on LastItemData).
    pub fn isItemToggledOpen(self: Ui) bool {
        return self.ctx.item_just_toggled;
    }

    /// Query a tree node's stored open state without rendering.
    /// Useful for parent code that wants to know if a section is
    /// expanded. `str_id` must hash to the same id the tree node
    /// uses (i.e. its label inside the current id-stack scope).
    /// Returns `default_open` if no state stored. Imgui:
    /// `TreeNodeGetOpen(id)`.
    pub fn treeNodeGetOpen(self: Ui, str_id: []const u8) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        const id: Id = widgetId(self.ctx, w, str_id);
        return self.ctx.tree_open_state.get(id) orelse false;
    }

    /// Horizontal offset from the tree-node's start-X to the
    /// label start. Useful for `sameLine`-aligning content
    /// underneath a node's label column. Returns
    /// `font_size + 4` (triangle width + gap), matching the
    /// existing `treeNodeImpl` layout. Imgui:
    /// `GetTreeNodeToLabelSpacing()`.
    pub fn getTreeNodeToLabelSpacing(self: Ui) f32 {
        return self.ctx.style.font_size + 4;
    }

    /// Manually push a horizontal indent on the cursor. Pair with
    /// `unindent()`. Useful for visual grouping outside of trees.
    pub fn indent(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.layout.indent_x += indent_amount;
        // The current row's cursor x ALSO needs to bump so the next
        // widget on this row honors the new indent. Subsequent rows
        // pick up `indent_x` via advanceLayout's reset.
        w.layout.cursor_pos[0] += indent_amount;
    }

    /// Pair with `indent()`. Decrements indent and pulls the current
    /// row's cursor back. Safe to call at zero indent (no-op).
    pub fn unindent(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        if (w.layout.indent_x >= indent_amount) {
            w.layout.indent_x -= indent_amount;
            w.layout.cursor_pos[0] -= indent_amount;
        }
    }

    // ---- Item state queries
    /// Was the most recently submitted item under the mouse this
    /// frame? Use after a widget call to attach hover behavior:
    ///     if (ui.button("Save",.{})) save();
    ///     if (ui.isItemHovered(.{})) ui.setTooltip("Ctrl+S",.{});
    /// Opts-arg form: pass `.{}` for the
    /// default "directly hovered, not blocked" semantics, or set
    /// fields on `HoveredFlags` for relaxed checks:
    ///     if (ui.isItemHovered(.{.allow_when_disabled = true }))...
    /// Today most `HoveredFlags` fields are accepted but NO-OP for
    /// items - they relax blocking checks zimr doesn't yet enforce
    /// (popup-blocking, active-item occlusion, per-item z-order,
    /// disabled-state, nav override). User code can set them
    /// today and pick up the behavior automatically when each
    /// machinery lands.
    /// Hover-delay flags are not on `HoveredFlags` yet - deferred
    /// until per-id hover-start timestamp state lands on
    /// `UiContext`. ImGui: `IsItemHovered(flags)`.
    pub fn isItemHovered(self: Ui, opts: HoveredFlags) bool {
        _ = opts;
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_hovered;
    }

    // ---- Mouse cursor + drag (Phase B1)
    /// Set the canvas cursor style. Pass the imgui-style mouse-cursor
    /// enum - `default`, `pointing_hand`, `ibeam`, `resize_ew`, etc.
    /// Pairs naturally with hover detection:
    ///     if (ui.isItemHovered(.{})) ui.setMouseCursor(.pointing_hand);
    /// The value persists until something else sets it (no per-frame
    /// reset), so most apps call this conditionally from their hover
    /// branches. No-op when the in-flight frame's input state isn't
    /// available - defensive for ctx-only test harnesses.
    pub fn setMouseCursor(self: Ui, cursor: MouseCursor) void {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return;
        runtime.input.setMouseCursor(s, cursor);
    }

    /// The cursor value most recently passed to `setMouseCursor`.
    /// Defaults to `.default` for a fresh `InputState`. Useful for
    /// stacked hover detection where multiple widgets may try to
    /// claim the cursor - read the current value, decide, then set.
    pub fn getMouseCursor(self: Ui) MouseCursor {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return .default;
        return runtime.input.getMouseCursor(s);
    }

    /// True while `button` is held and the cursor has moved more than
    /// `lock_threshold` pixels from where the button was pressed. Pass
    /// `-1` for the imgui-default threshold (6 px). Distinguishes
    /// click-vs-drag for custom widgets:
    ///     if (ui.isMouseDragging(.left, -1)) {
    ///         const d = ui.getMouseDragDelta(.left, -1);
    ///         widget_pos.x += d.x; widget_pos.y += d.y;
    ///         ui.resetMouseDragDelta(.left);
    ///     }
    /// True while `key` is currently held. Reads the per-frame
    /// `InputSnapshot.keys` array — no syscall, no runtime input
    /// lookup, just an array index. Stable across the whole frame.
    /// Mirrors imgui's `IsKeyDown` (imgui.h:1097).
    pub fn isKeyDown(self: Ui, key: KeyCode) bool {
        return self.ctx.input.keys[@intFromEnum(key)].down;
    }

    /// True for exactly one frame: the frame `key` transitioned
    /// up→down. Edge event. Mirrors imgui's `IsKeyPressed`
    /// (imgui.h:1098). This is one-shot only; for key-repeat
    /// firing, use `isKeyPressedOrRepeat`.
    pub fn isKeyPressed(self: Ui, key: KeyCode) bool {
        return self.ctx.input.keys[@intFromEnum(key)].pressed_this_frame;
    }

    /// True for exactly one frame: the frame `key` transitioned
    /// down→up. Edge event. Mirrors imgui's `IsKeyReleased`
    /// (imgui.h:1099).
    pub fn isKeyReleased(self: Ui, key: KeyCode) bool {
        return self.ctx.input.keys[@intFromEnum(key)].released_this_frame;
    }

    /// True if either left or right Shift is currently held.
    /// Convenience for the very common case of testing a modifier
    /// without caring which side. Mirrors imgui's `io.KeyShift`.
    pub fn isShiftDown(self: Ui) bool {
        return self.ctx.input.keys[@intFromEnum(KeyCode.left_shift)].down or
            self.ctx.input.keys[@intFromEnum(KeyCode.right_shift)].down;
    }

    /// True if either left or right Control is currently held.
    /// On macOS this is the literal Control key, NOT Cmd —
    /// platform-aware modifier remapping (imgui's KeyCtrl-on-mac =
    /// Cmd convention) is not yet done; callers on mac who want
    /// "command-or-control" should check both `isCtrlDown` and
    /// `isSuperDown`.
    pub fn isCtrlDown(self: Ui) bool {
        return self.ctx.input.keys[@intFromEnum(KeyCode.left_control)].down or
            self.ctx.input.keys[@intFromEnum(KeyCode.right_control)].down;
    }

    /// True if either left or right Alt is currently held.
    pub fn isAltDown(self: Ui) bool {
        return self.ctx.input.keys[@intFromEnum(KeyCode.left_alt)].down or
            self.ctx.input.keys[@intFromEnum(KeyCode.right_alt)].down;
    }

    /// True if either left or right Super (Windows / Command) is held.
    pub fn isSuperDown(self: Ui) bool {
        return self.ctx.input.keys[@intFromEnum(KeyCode.left_super)].down or
            self.ctx.input.keys[@intFromEnum(KeyCode.right_super)].down;
    }

    /// `isKeyPressed` plus key-repeat: fires on initial press AND
    /// at the OS-defined repeat cadence while held. Used by text
    /// input for backspace/delete/arrow keys so the user can hold
    /// the key for accelerated motion/deletion. Mirrors imgui's
    /// `IsKeyPressed(key, repeat=true)` (the default `repeat=true`
    /// variant — `imgui.h:1098`).
    ///
    /// Implementation routes through raylib's
    /// `isKeyPressedRepeat` for the held-and-repeating case
    /// (because raylib owns the OS-level repeat timer; mirroring
    /// it in zimr would drift). Initial-press case uses the
    /// snapshot for consistency with `isKeyPressed`.
    pub fn isKeyPressedOrRepeat(self: Ui, key: KeyCode) bool {
        if (self.isKeyPressed(key)) {
            return true;
        }
        const s: *runtime.input.InputState = self.ctx.input_state orelse return false;
        return runtime.input.isKeyPressedRepeat(s, key.toRaylib());
    }

    /// Result of `getOrPutState`. Mirrors `GetOrPutResult` from
    /// `std.AutoHashMapUnmanaged` — the caller branches on
    /// `found_existing` to decide whether to write `value_ptr` with
    /// an initial value or read what's already there.
    ///
    /// `value_ptr` is stable until the next `getOrPutState(T,...)`
    /// for the same `T`. Don't hold across calls.
    pub fn StateGetOrPut(comptime T: type) type {
        return struct {
            value_ptr: *T,
            found_existing: bool,
        };
    }

    /// Look up (T, id) — return a slot pointer plus whether it
    /// existed. When `!found_existing`, the slot's contents are
    /// **undefined** (matches `std.HashMap.getOrPut` semantics) —
    /// the caller MUST write through `value_ptr` before reading.
    ///
    /// Standard idiom for a type with a sensible zero / default:
    ///
    ///     const r = u.getOrPutState(MyKnobState, id,.{});
    ///     if (!r.found_existing) r.value_ptr.* =.{};
    ///     r.value_ptr.angle += delta;
    ///
    /// For a type without a default, supply the seed explicitly:
    ///
    ///     const r = u.getOrPutState(DragState, id,.{});
    ///     if (!r.found_existing) r.value_ptr.* =.{
    ///         .origin = mouse_pos,.active = true,
    ///     };
    ///
    /// One hash on the outer map, one on the inner map. No
    /// allocation in the common case (slot already exists).
    ///
    /// `opts.persist = true` enrolls T in the on-disk save/load
    /// path. Flag is sticky once set — re-passing `false` after
    /// a `true` does not disenroll, because some other call site
    /// may rely on the registration.
    pub fn getOrPutState(
        self: Ui,
        comptime T: type,
        id: Id,
        opts: StateOpts,
    ) StateGetOrPut(T) {
        const key: u64 = comptime extStorageKey(T);

        const OuterGop = std.AutoHashMapUnmanaged(u64, ExtStorageSlot).GetOrPutResult;
        const outer: OuterGop = self.ctx.ext_storage.getOrPut(self.ctx.gpa, key) catch
            @panic("ext_storage outer-map OOM");
        if (!outer.found_existing) {
            const inner: *std.AutoHashMapUnmanaged(Id, T) =
                self.ctx.gpa.create(std.AutoHashMapUnmanaged(Id, T)) catch
                    @panic("ext_storage inner-map OOM");
            inner.* = .{};
            outer.value_ptr.* = .{
                .map = @ptrCast(inner),
                .deinit_fn = deinitErasedMap(T),
                .has_persist = false,
            };
        }

        // Persist enrollment is sticky and bound on first true.
        // The fn closures need T comptime-known — here is the only
        // place that's available.
        if (opts.persist and !outer.value_ptr.has_persist) {
            outer.value_ptr.has_persist = true;
            outer.value_ptr.type_name = @typeName(T);
            outer.value_ptr.serialize_fn = serializeErasedMap(T);
            outer.value_ptr.deserialize_fn = deserializeErasedMap(T);
        }

        const typed: *std.AutoHashMapUnmanaged(Id, T) =
            @ptrCast(@alignCast(outer.value_ptr.map));
        const InnerGop = std.AutoHashMapUnmanaged(Id, T).GetOrPutResult;
        const inner: InnerGop = typed.getOrPut(self.ctx.gpa, id) catch
            @panic("ext_storage inner-map id OOM");
        return .{ .value_ptr = inner.value_ptr, .found_existing = inner.found_existing };
    }

    /// Read-only state lookup. Returns null if no slot exists for
    /// (T, id). Use when you want to react to existing state but
    /// not create it — most callers want `getOrPutState`.
    pub fn getState(
        self: Ui,
        comptime T: type,
        id: Id,
    ) ?*T {
        const key: u64 = comptime extStorageKey(T);
        const slot: *ExtStorageSlot = self.ctx.ext_storage.getPtr(key) orelse return null;
        const typed: *std.AutoHashMapUnmanaged(Id, T) = @ptrCast(@alignCast(slot.map));
        return typed.getPtr(id);
    }

    // Three primitives that compose into "the minimum extension-
    // author surface" — given (id, rect, opts), do all the
    // boilerplate (clip test, hovered_id update, press/release
    // machine, button_repeat, keyboard activation) that every
    // widget needs. Used by zimr's built-in button/checkbox/
    // radio/selectable/arrowButton/smallButton.
    //

    // Imgui ref: `imgui_internal.h:3546-3548` (ItemSize, ItemAdd)
    // + `imgui_internal.h:3968` (ButtonBehavior).

    /// Advance the layout cursor by `size`, marking that a widget
    /// of those dimensions occupied the current cursor position.
    /// Must be called by every widget at end-of-submission, AFTER
    /// drawing (so the cursor advance happens once).
    ///
    /// Mirrors imgui's `ItemSize(ImVec2)`. Imgui's optional
    /// `text_baseline_y` is not surfaced — zimr widgets don't
    /// currently use cross-baseline alignment. Add if needed.
    pub fn itemSize(self: Ui, size: Vec2) void {
        const w: *Window = self.ctx.current_window orelse return;
        advanceLayout(w, size, self.ctx.style.item_spacing, 0);
    }

    /// Register a widget rectangle with the given `id`, returning
    /// `true` if the widget should be drawn this frame. Returns
    /// `false` when the rect is fully outside the current clip
    /// rect (so the caller can skip drawing entirely — early
    /// exit). Side effects: sets `ctx.hovered_id = id` when the
    /// mouse is over the rect.
    ///
    /// Mirrors imgui's `ItemAdd(const ImRect& bb, ImGuiID id)`.
    /// Today the clip-rect check is simple "is rect within
    /// window bounds"; future canvas / scroll-clip integration
    /// (Q4) will tighten this.
    ///
    /// The CALLER computes the rect and the id (`u.getId(label)`).
    /// itemAdd doesn't claim the layout cursor — pair with
    /// `itemSize` after drawing.
    pub fn itemAdd(
        self: Ui,
        rect: Rectangle,
        id: Id,
    ) bool {
        const w: *Window = self.ctx.current_window orelse return false;

        // Clip test: is the rect outside the window's content area?
        // Cheap "is fully outside" check. Partial overlap returns
        // true (caller draws + the renderer clips at scissor time).
        const wx: f32 = w.pos[0];
        const wy: f32 = w.pos[1];
        const wx2: f32 = w.pos[0] + w.size[0];
        const wy2: f32 = w.pos[1] + w.size[1];
        const fully_outside: bool =
            rect.x + rect.width <= wx or
            rect.x >= wx2 or
            rect.y + rect.height <= wy or
            rect.y >= wy2;
        if (fully_outside) {
            return false;
        }

        // Hovered update. pointInRect is the same test
        // buttonImpl uses, so the behavior is consistent.
        if (pointInRect(self.ctx.input.mouse_pos, rect)) {
            self.ctx.hovered_id = id;
        }
        return true;
    }

    /// Output of `buttonBehavior`: every edge event + level state
    /// the extension might react to. All booleans are stable for
    /// one frame — read once, branch as needed.
    pub const ButtonResult = struct {
        /// True for one frame when the button "fires" — release-
        /// inside-rect after a press OR a `button_repeat` tick OR
        /// keyboard activation while focused. This is what
        /// existing `pressed` returns from `u.button`.
        pressed: bool,
        /// True while the mouse is over the widget's rect this
        /// frame. Same as `ctx.hovered_id == id` AFTER itemAdd
        /// runs.
        hovered: bool,
        /// True while `ctx.active_id == id` (mouse pressed inside
        /// the rect, button still down). Useful for "darken on
        /// hold" visual feedback.
        held: bool,
        /// True for ONE frame: the frame the user clicked into
        /// this widget (mouse_down inside the rect). Same as
        /// imgui's `g.ActiveIdJustActivated` for this id.
        just_activated: bool,
        /// True for ONE frame: the frame `active_id` returned to 0
        /// because this widget released (regardless of inside/
        /// outside). Useful for drag-end snap-back patterns.
        just_deactivated: bool,
    };

    /// Options for `buttonBehavior`. Subset of imgui's
    /// `ImGuiButtonFlags`; zimr's per-widget `ItemFlags` covers
    /// the same ground for built-ins, but extension authors
    /// using `buttonBehavior` directly want a self-contained
    /// surface that doesn't depend on `pushItemFlag` ceremony.
    pub const ButtonBehaviorOpts = struct {
        /// When true, fires `pressed` periodically while held.
        /// Cadence matches imgui defaults: 400ms initial delay,
        /// 50ms repeat thereafter. Same machinery as
        /// `ItemFlags.button_repeat` on built-in widgets.
        repeat: bool = false,
    };

    /// Run the press / release / repeat / keyboard-activation
    /// state machine for `id` over the rect `rect`. Returns
    /// edge events + level state in a `ButtonResult`. Doesn't
    /// draw anything — caller draws based on `result.hovered` /
    /// `held`. Doesn't claim the layout — caller pairs with
    /// `itemSize(rect_size)` after drawing.
    ///
    /// Mirrors imgui's `ButtonBehavior(const ImRect&, ImGuiID,
    /// bool*, bool*, ImGuiButtonFlags)`. Output struct replaces
    /// imgui's out-pointer pattern.
    ///
    /// The mouse hit test inside this fn uses `pointInRect` for
    /// consistency with `itemAdd`. Calling pattern:
    ///
    ///     const rect =...;
    ///     const id = u.getId("knob");
    ///     u.itemSize(rect_size); // OR after the draw if you
    ///                             // want pixel-exact "current
    ///                             // line height" tracking
    ///     if (!u.itemAdd(rect, id)) return;
    ///     const r: Ui.ButtonResult = u.buttonBehavior(rect, id,.{});
    ///     // draw using r.hovered / r.held / r.pressed
    pub fn buttonBehavior(
        self: Ui,
        rect: Rectangle,
        id: Id,
        opts: ButtonBehaviorOpts,
    ) ButtonResult {
        const ctx: *UiContext = self.ctx;
        const hovered: bool = pointInRect(ctx.input.mouse_pos, rect);
        var pressed: bool = false;
        var just_activated: bool = false;
        var just_deactivated: bool = false;

        // Mouse-down inside rect → claim active_id.
        if (hovered and ctx.input.mouse_left_clicked) {
            ctx.active_id = id;
            ctx.active_id_just_activated = true;
            ctx.active_id_press_time = ctx.frame_time_seconds;
            ctx.active_id_last_repeat_time = ctx.frame_time_seconds;
            just_activated = true;
        }
        // Mouse-up while we own active_id → fire pressed if still
        // hovered; release the claim either way.
        if (ctx.active_id == id and ctx.input.mouse_left_released) {
            if (hovered) {
                pressed = true;
            }
            ctx.active_id = 0;
            just_deactivated = true;
        }
        // Repeat: 400ms delay, then 50ms cadence. Held = active_id
        // is us AND mouse-down AND cursor still over us.
        if (opts.repeat and ctx.active_id == id and
            ctx.input.mouse_left_down and hovered)
        {
            const REPEAT_DELAY: f32 = 0.4;
            const REPEAT_RATE: f32 = 0.05;
            const since_press: f32 = ctx.frame_time_seconds - ctx.active_id_press_time;
            if (since_press >= REPEAT_DELAY) {
                const since_last: f32 = ctx.frame_time_seconds - ctx.active_id_last_repeat_time;
                if (since_last >= REPEAT_RATE) {
                    pressed = true;
                    ctx.active_id_last_repeat_time = ctx.frame_time_seconds;
                }
            }
        }
        // keyboard nav: Enter on focused item fires pressed.
        if (triggerNavActivate(ctx, id)) {
            pressed = true;
        }

        return .{
            .pressed = pressed,
            .hovered = hovered,
            .held = ctx.active_id == id,
            .just_activated = just_activated,
            .just_deactivated = just_deactivated,
        };
    }

    /// Override one or more style fields for a scope. Returns a
    /// `StyleGuard` that restores the prior values when
    /// `g.restore()` is called. Use with `defer`:
    ///
    ///     const g = u.styleOverride(.{.text = my_red });
    ///     defer g.restore();
    ///     u.text("warning",.{});
    ///
    /// Only the fields you set on `overrides` are touched — null
    /// fields leave the live style alone. Nesting is safe: each
    /// guard saves the value live at the time IT ran, so multiple
    /// stacked overrides restore correctly in defer-stack order.
    pub fn styleOverride(self: Ui, overrides: PartialStyle) StyleGuard {
        var saved: PartialStyle = .{};
        inline for (@typeInfo(Style).@"struct".field_names) |f_name| {
            if (@field(overrides, f_name)) |new_val| {
                // Capture prior, apply new.
                @field(saved, f_name) = @field(self.ctx.style, f_name);
                @field(self.ctx.style, f_name) = new_val;
            }
        }
        return .{ .ctx = self.ctx, .saved = saved };
    }

    // A canvas is a rectangular sub-region of the current window
    // with its own clip rect. Caller draws through a
    // `DrawListHandle` that's pre-bracketed with push_clip /
    // pop_clip, so anything drawn lands scissored to the canvas
    // bounds.
    //

    // Pattern:
    //     if (u.beginCanvas("graph",.{ 400, 300 },.{})) |c| {
    //         defer u.endCanvas(c);
    //         c.drawList().addCircleFilled(.{ 200, 150 }, 50, 0xFFFF0000);
    //         // mouse position in the canvas's authoring space:
    //         const lm = c.localMouse();
    //     }
    //

    // The canvas's draws go on the SAME parent-window drawlist,
    // scissored. No separate DrawList struct, no channel-pair
    // splitter. If z-order control becomes a real need, channels
    // can wrap on top later without disturbing this surface.

    /// Options for `beginCanvas`. Empty for now; the transform
    /// stack is push/popped imperatively via
    /// `CanvasCtx.pushTransform` rather than passed in at begin.
    pub const CanvasOpts = struct {
        /// Padding field — Zig disallows zero-field structs in
        /// some positional contexts. Remove when the struct
        /// gains a real field.
        _: u1 = 0,
    };

    /// Live state of an active canvas scope. Returned by
    /// `beginCanvas` when the canvas is visible; pass to
    /// `endCanvas` to close out. Field access is the canvas's
    /// public surface — see method docs.
    pub const CanvasCtx = struct {
        ctx: *UiContext,
        /// Window the canvas was opened inside; used by endCanvas
        /// to find the drawlist to pop_clip on.
        window: *Window,
        /// Screen-space rectangle the canvas occupies. Cached so
        /// `localMouse()` doesn't re-resolve it.
        rect: Rectangle,
        /// Canvas ID (hashStr of the label). Same shape every
        /// other widget uses for state-keyed lookup.
        id: Id,
        /// Depth of the canvas_transforms stack at begin time.
        /// `endCanvas` truncates back to this depth — guards
        /// against caller-side push-without-pop.
        transform_depth_at_begin: usize,

        /// Get a `DrawListHandle` for this canvas's draws. The
        /// handle wraps the parent window's drawlist; commands
        /// recorded through it are bracketed by the push_clip
        /// already emitted at begin time, so they're scissored
        /// to the canvas bounds at render time.
        pub fn drawList(self: CanvasCtx) DrawListHandle {
            return .{
                .list = &self.window.draw_list,
                .gpa = self.ctx.frame_arena.allocator(),
            };
        }

        /// Mouse position in canvas authoring space. Subtracts
        /// the canvas's screen origin to get canvas-local coords,
        /// then applies the INVERSE of the current cumulative
        /// transform so callers always read coordinates in their
        /// authoring frame — whatever pushTransform layers have
        /// been added.
        ///
        /// With no transforms pushed, the inverse is identity, so
        /// this is `screen_mouse - rect.{x,y}` (matching the
        /// 422 MVP behavior).
        pub fn localMouse(self: CanvasCtx) Vec2 {
            const m: Vec2 = self.ctx.input.mouse_pos;
            const in_canvas: Vec2 = .{ m[0] - self.rect.x, m[1] - self.rect.y };
            const cur: ?CanvasTransform = self.ctx.canvas_transforms.top();
            if (cur) |t| {
                return t.inverse().applyLocal(in_canvas);
            }
            return in_canvas;
        }

        /// Push a translate+scale transform onto the canvas's
        /// transform stack. Subsequent `localMouse` calls will
        /// see coordinates in the post-transform authoring space.
        /// The stack composes — pushing (t1, s1) then (t2, s2)
        /// gives a top of `compose(parent={t1,s1}, child={t2,s2})`,
        /// so the math behaves exactly like nested coordinate
        /// systems. Pair with `popTransform()` — typically via
        /// `defer c.popTransform()`.
        ///
        /// Returns silently on overflow (stack capped at 16
        /// entries); a deeper nest is almost certainly a bug.
        pub fn pushTransform(
            self: CanvasCtx,
            translate: Vec2,
            scale: Vec2,
        ) void {
            const new_local: CanvasTransform = .{ .translate = translate, .scale = scale };
            const composed: CanvasTransform = if (self.ctx.canvas_transforms.top()) |parent|
                parent.compose(new_local)
            else
                new_local;
            self.ctx.canvas_transforms.append(composed) catch return;
        }

        /// Pop the most recently pushed transform. No-op when
        /// the stack is empty (safer than a panic since it lets
        /// stray pops in error paths still compile).
        pub fn popTransform(self: CanvasCtx) void {
            _ = self.ctx.canvas_transforms.pop();
        }

        /// Get the cumulative current transform. Returns identity
        /// when nothing has been pushed. This is the transform
        /// from authoring-space to CANVAS-LOCAL space (i.e. relative
        /// to the canvas top-left, NOT screen coords). Most callers
        /// want `toScreen()` instead, which composes this with the
        /// canvas-origin translation.
        pub fn currentTransform(self: CanvasCtx) CanvasTransform {
            return self.ctx.canvas_transforms.top() orelse .{};
        }

        /// Project an authoring-space point to SCREEN coords —
        /// suitable for handing to `drawList().add*()` calls.
        /// Applies the cumulative transform AND the canvas-origin
        /// offset, so the inverse of `localMouse()`:
        ///   `toScreen(localMouse()) == ctx.input.mouse_pos`.
        /// Use this for any geometry you compute in authoring space
        /// and want to draw — addRectFilled, addBezierCubic, etc.
        /// take screen coords (the drawlist is the parent window's,
        /// not a canvas-local one — see ADR Q4).
        pub fn toScreen(self: CanvasCtx, p: Vec2) Vec2 {
            const xf: CanvasTransform = self.currentTransform();
            const local: Vec2 = xf.applyLocal(p);
            return .{ local[0] + self.rect.x, local[1] + self.rect.y };
        }

        /// True when the mouse is within the canvas's clip rect
        /// AND the canvas's parent window is the topmost window
        /// at the mouse position (i.e. no popup, tooltip, or
        /// other window is covering the canvas at the mouse).
        /// The window-id check is what distinguishes "mouse-in-rect"
        /// from "this canvas owns the hover" — a popup drawn over
        /// the canvas claims the topmost window without disturbing
        /// the canvas's rect.
        pub fn hovered(self: CanvasCtx) bool {
            if (!pointInRect(self.ctx.input.mouse_pos, self.rect)) {
                return false;
            }
            return self.ctx.hovered_window_id == self.window.id;
        }
    };

    /// Open a canvas scope. Returns null when the canvas is
    /// clipped entirely outside the current window's content area
    /// (so the caller can skip the canvas body entirely). When
    /// non-null, the caller MUST pair with `endCanvas` — typically
    /// via `defer`.
    pub fn beginCanvas(
        self: Ui,
        label: []const u8,
        size: Vec2,
        opts: CanvasOpts,
    ) ?CanvasCtx {
        _ = opts;
        const w: *Window = self.ctx.current_window orelse return null;
        const id: Id = widgetId(self.ctx, w, label);

        // Place at current cursor.
        const at: Vec2 = resolveCursor(w, self.ctx.style.item_spacing[0]);
        const rect: Rectangle = .{
            .x = at[0],
            .y = at[1],
            .width = size[0],
            .height = size[1],
        };

        // Layout: claim the cursor space immediately (so subsequent
        // sibling widgets land below the canvas).
        advanceLayout(w, size, self.ctx.style.item_spacing, 0);

        // Clip: skip entirely if outside window bounds.
        if (!self.itemAdd(rect, id)) {
            return null;
        }

        // Push the clip rect onto the window's drawlist. Anything
        // recorded through `c.drawList()` between now and
        // `endCanvas` will be scissored.
        w.draw_list.pushClipRect(self.ctx.frame_arena.allocator(), rect);

        return .{
            .ctx = self.ctx,
            .window = w,
            .rect = rect,
            .id = id,
            .transform_depth_at_begin = self.ctx.canvas_transforms.len,
        };
    }

    /// Close a canvas scope. Pops the clip rect that `beginCanvas`
    /// pushed, and truncates the transform stack back to its
    /// depth at begin time (so any pushTransforms the caller
    /// forgot to pop don't leak to the next canvas). Safe to
    /// call exactly once per matching begin.
    pub fn endCanvas(self: Ui, c: CanvasCtx) void {
        _ = self;
        // Restore transform-stack depth — defensive cleanup for
        // callers who pushed but didn't pop. In well-behaved code
        // this is a no-op.
        c.ctx.canvas_transforms.len = c.transform_depth_at_begin;
        c.window.draw_list.popClipRect(c.ctx.frame_arena.allocator());
    }

    // ========================================================================
    // Animation primitives
    // ========================================================================
    //
    // Two flavors of motion, both keyed by a stable label string:
    //
    //   `u.animated(label, opts)` — one-shot tween from `from` to
    //       `to` over `duration` seconds.  Restarts if `to` changes.
    //
    //   `u.spring(label, opts)` — physical spring following
    //       `target`. Tracks `target` smoothly as it moves between
    //       frames.  Critically-damped by default (no overshoot).
    //
    // Both return the current value to plug into a draw call:
    //
    //     const alpha = u.animated("fade", .{
    //         .from = 0, .to = 1, .duration = 0.3, .easing = .ease_out,
    //     });
    //     u.fillRect(rect, .{ .r = 255, .g = 0, .b = 0, .a = @trunc(alpha * 255) });
    //
    // State lives in `ext_storage` via `getOrPutState`, so it
    // gets persistence for free if a caller opts in — though
    // animations rarely want that.

    /// One-shot tween.  Returns the current interpolated value.
    /// Reads `ctx.input.delta_time` to advance the slot.
    ///
    /// If `opts.to` differs from the previous frame's `to`, the
    /// tween restarts — useful for "snap to a new target" patterns.
    pub fn animated(
        self: Ui,
        label: []const u8,
        opts: AnimatedOpts,
    ) f32 {
        const ctx: *UiContext = self.ctx;
        const id: Id = hashStr(0, label);
        const r = self.getOrPutState(TweenSlot, id, .{});
        if (!r.found_existing) {
            r.value_ptr.* = .{};
        }
        const s: *TweenSlot = r.value_ptr;

        // Detect a target change.  On the first frame the slot is
        // fresh (seeded == false); just remember `to` and start.
        if (!s.seeded) {
            s.last_to = opts.to;
            s.seeded = true;
        } else if (s.last_to != opts.to) {
            s.elapsed = 0;
            s.last_to = opts.to;
        }

        s.elapsed += ctx.input.delta_time;

        // Delay phase: hold at `from`.
        if (s.elapsed < opts.delay) {
            return opts.from;
        }
        if (opts.duration <= 0) {
            // Degenerate duration — snap to `to`.
            return opts.to;
        }
        const since_motion: f32 = s.elapsed - opts.delay;
        if (since_motion >= opts.duration) {
            return opts.to;
        }
        const t: f32 = since_motion / opts.duration;
        const eased: f32 = applyEasing(opts.easing, t);
        return opts.from + (opts.to - opts.from) * eased;
    }

    /// Physical spring following `opts.target`.  Returns the
    /// current value.  Each call advances the spring by one
    /// `delta_time` step using semi-implicit Euler integration.
    ///
    /// Pass a new `opts.target` each frame; the spring smoothly
    /// follows.  At rest, the call is cheap (zero work beyond a
    /// settle test).
    pub fn spring(
        self: Ui,
        label: []const u8,
        opts: SpringOpts,
    ) f32 {
        const ctx: *UiContext = self.ctx;
        const id: Id = hashStr(0, label);
        const r = self.getOrPutState(SpringSlot, id, .{});
        if (!r.found_existing) {
            r.value_ptr.* = .{};
        }
        const s: *SpringSlot = r.value_ptr;

        if (!s.seeded) {
            s.value = opts.initial;
            s.velocity = 0;
            s.seeded = true;
        }

        // Semi-implicit Euler step for a damped harmonic
        // oscillator:  v += (-k * (x - target) - c * v) * dt
        //              x += v * dt
        // where k = stiffness, c = 2 * damping * sqrt(k).  The
        // factor of two-sqrt-k makes `damping = 1.0` mean
        // critical damping (textbook definition).
        const dt: f32 = ctx.input.delta_time;
        const k: f32 = opts.stiffness;
        const c: f32 = 2.0 * opts.damping * @sqrt(k);
        const dx: f32 = s.value - opts.target;
        const accel: f32 = -k * dx - c * s.velocity;
        s.velocity += accel * dt;
        s.value += s.velocity * dt;

        // Settle: when velocity AND offset-from-target are both
        // negligible relative to the target's magnitude, snap
        // exactly to target and zero velocity.  Avoids perpetual
        // sub-pixel jitter and lets callers branch on "spring at
        // rest" via `value == target`.  The threshold scales with
        // |target| so springs that track large numerical ranges
        // (e.g. pixel coords in the hundreds-of-pixels regime)
        // settle as readily as small ones.
        const settle_scale: f32 = @max(@abs(opts.target), 1.0);
        const SETTLE_REL: f32 = 0.001;
        if (@abs(s.velocity) < SETTLE_REL * settle_scale and
            @abs(dx) < SETTLE_REL * settle_scale)
        {
            s.value = opts.target;
            s.velocity = 0;
        }

        return s.value;
    }

    // ========================================================================
    // miniPlot — throwaway smoke test for the architectural pillars
    // ========================================================================
    //
    // **Will be deleted when real `beginPlot` lands** (planned Tier 4).
    // The point of this stub is to find integration bugs between the
    // canvas / arcs / state / style / input / animation pillars NOW,
    // while the cost of fixing them is small.  A real plot stack is
    // 30+ turns of work; an integration bug in one of the underlying
    // primitives is much cheaper to fix in isolation than threaded
    // through that whole stack.
    //
    // Pillars touched, in order:
    //   - canvas (the plot draws inside `beginCanvas` / `endCanvas`)
    //   - getOrPutState (caches the auto-fit Y limits per label)
    //   - styleOverride (pushes plot-specific bg + grid color)
    //   - isKeyPressed (R resets auto-fit)
    //   - spring (animates limits when they change between frames)
    //   - addPolyline + addCircleFilled + addArc (the actual draw)

    /// Single-line plot in a canvas-clipped rectangle.  No axes,
    /// no title, no markers beyond a "latest value" dot — just
    /// enough to verify the pillars compose.  `xs` and `ys` must
    /// be the same length; `xs` must be monotonic ascending for
    /// the line to render sensibly (not enforced).
    ///
    /// Returns `true` if the user pressed R while the plot was
    /// hovered (a courtesy signal — most callers ignore it).
    pub fn miniPlot(
        self: Ui,
        label: []const u8,
        rect: Rectangle,
        xs: []const f32,
        ys: []const f32,
    ) bool {
        if (xs.len == 0 or xs.len != ys.len) {
            return false;
        }

        // Q5: scope a style override for the plot's own colors.
        // The plot looks "different" from the rest of the window
        // (darker bg, brighter grid) without persistent state.
        const guard: StyleGuard = self.styleOverride(.{
            .frame_bg = .{ .r = 8, .g = 10, .b = 16, .a = 255 },
            .border = .{ .r = 40, .g = 60, .b = 90, .a = 255 },
        });
        defer guard.restore();

        // Q2: per-label slot for auto-fit + animation targets.
        const id: Id = hashStr(0, label);
        const r = self.getOrPutState(MiniPlotSlot, id, .{});
        if (!r.found_existing) {
            r.value_ptr.* = .{};
        }
        const slot: *MiniPlotSlot = r.value_ptr;

        // Q1: R-key resets the auto-fit so the next frame recomputes.
        // We could check hover, but for a smoke test global is fine.
        var r_pressed: bool = false;
        if (self.isKeyPressed(.r)) {
            slot.fitted = false;
            r_pressed = true;
        }

        // Auto-fit on first frame or after a reset.  10% padding
        // so the line doesn't kiss the edges.
        if (!slot.fitted) {
            var lo: f32 = ys[0];
            var hi: f32 = ys[0];
            for (ys) |y| {
                if (y < lo) {
                    lo = y;
                }
                if (y > hi) {
                    hi = y;
                }
            }
            const pad: f32 = @max((hi - lo) * 0.1, 0.001);
            slot.targetMin = lo - pad;
            slot.targetMax = hi + pad;
            slot.fitted = true;
        }

        // Q9: spring the live limits toward their targets.  When
        // `ys` changes shape and the targets move, the animated
        // values lag and produce a smooth zoom.  Two distinct
        // labels so the springs don't share state.
        const cur_min: f32 = self.spring("miniplot_min", .{
            .target = slot.targetMin,
            .initial = slot.targetMin,
        });
        const cur_max: f32 = self.spring("miniplot_max", .{
            .target = slot.targetMax,
            .initial = slot.targetMax,
        });

        // Q4: a canvas hosts the actual draws.  Coords are
        // window-relative; we use the canvas's drawList handle.
        if (self.beginCanvas(label, .{ rect.width, rect.height }, .{})) |c| {
            defer self.endCanvas(c);

            const dl: DrawListHandle = c.drawList();
            // Background.
            dl.addRectFilled(
                .{ .x = 0, .y = 0, .width = rect.width, .height = rect.height },
                self.ctx.style.frame_bg,
            );

            // A few horizontal grid lines — three rows.
            var i: u32 = 1;
            while (i < 4) : (i += 1) {
                const fy: f32 = (float(i) / 4.0) * rect.height;
                dl.addLine(
                    .{ 0, fy },
                    .{ rect.width, fy },
                    self.ctx.style.border,
                    1,
                );
            }

            // Map xs/ys → screen rect.  X spans the full width
            // proportional to position in the array; Y maps from
            // [cur_min, cur_max] flipped (top = max).
            const span: f32 = cur_max - cur_min;
            const safe_span: f32 = if (span > 1e-6) span else 1;
            const points: []Vec2 = self.ctx.frame_arena.allocator().alloc(Vec2, ys.len) catch return r_pressed;
            for (ys, 0..) |y, k| {
                const fx: f32 = (float(k) / float(ys.len - 1)) * rect.width;
                const fy: f32 = rect.height - ((y - cur_min) / safe_span) * rect.height;
                points[k] = .{ fx, fy };
            }
            dl.addPolyline(points, Color.fromWire(0xFF6DB0E0), 1.5, false);

            // Latest-value dot + a small arc behind it as an
            // exercise for Q7's arc primitives.
            if (ys.len >= 1) {
                const last: Vec2 = points[points.len - 1];
                dl.addArcFilled(last, 6, 0, tau, Color.fromWire(0x402090F0));
                dl.addCircleFilled(last, 3, Color.fromWire(0xFFF0F4FA));
            }
        }

        return r_pressed;
    }

    /// Options for `beginItem`. Caller specifies a `size` and the
    /// helper handles ID hashing, cursor placement, rect
    /// computation, clip-test, hovered-id update, AND the
    /// button-behavior state machine in one call. The single-call
    /// surface is for "I just want to write a widget and be done"
    /// — for fine-grained control (custom hit shapes, multi-rect
    /// widgets, non-button interaction patterns), reach for the
    /// B-path primitives (`itemAdd`/`buttonBehavior`) directly.
    pub const ItemOpts = struct {
        /// Widget size in pixels. Caller computes this from
        /// content (text width + padding etc). No "auto-size from
        /// label" today — the label is for the ID, not for sizing.
        size: Vec2,
        /// Same as `ButtonBehaviorOpts.repeat`. Default off.
        repeat: bool = false,
    };

    /// Result of `beginItem`. Same field set as `ButtonResult`
    /// plus `id` and `rect`. `null` from beginItem means the rect
    /// was fully clipped — caller skips drawing entirely.
    pub const ItemCtx = struct {
        id: Id,
        rect: Rectangle,
        pressed: bool,
        hovered: bool,
        held: bool,
        just_activated: bool,
        just_deactivated: bool,
    };

    /// Single-call widget primitive. Composes `widgetId` +
    /// `resolveCursor` + `itemAdd` + `buttonBehavior` into one
    /// "do all the boilerplate" call. Returns `null` if the
    /// widget's rect is fully outside the window (caller skips
    /// drawing); otherwise returns the full state needed to draw
    /// + react.
    ///
    /// Pair with `endItem(ctx)` (which calls `itemSize`). Use
    /// `defer` to make pairing mistake-proof:
    ///
    ///     if (u.beginItem("knob",.{.size =.{ 40, 40 } })) |it| {
    ///         defer u.endItem(it);
    ///         // draw using it.hovered / it.held / it.rect
    ///         return it.pressed;
    ///     }
    ///
    /// Imgui has no canonical equivalent — its widgets reimplement
    /// the boilerplate inline. Q3's two-path design (B for
    /// imgui-style + C for opinionated) gives extension authors
    /// the choice; C is the recommended starting point.
    pub fn beginItem(
        self: Ui,
        label: []const u8,
        opts: ItemOpts,
    ) ?ItemCtx {
        const w: *Window = self.ctx.current_window orelse return null;
        const id: Id = widgetId(self.ctx, w, label);
        const at: Vec2 = resolveCursor(w, self.ctx.style.item_spacing[0]);
        const rect: Rectangle = .{
            .x = at[0],
            .y = at[1],
            .width = opts.size[0],
            .height = opts.size[1],
        };
        if (!self.itemAdd(rect, id)) {
            // Still advance the cursor on clipped items — matches
            // imgui semantics so subsequent widgets land in the
            // expected position regardless of clipping.
            self.itemSize(opts.size);
            return null;
        }
        const r: ButtonResult = self.buttonBehavior(rect, id, .{ .repeat = opts.repeat });
        return .{
            .id = id,
            .rect = rect,
            .pressed = r.pressed,
            .hovered = r.hovered,
            .held = r.held,
            .just_activated = r.just_activated,
            .just_deactivated = r.just_deactivated,
        };
    }

    /// Pair with `beginItem`. Advances the layout cursor by the
    /// item's size. Pass the `ItemCtx` returned from `beginItem`
    /// so the helper has the rect — and so a mistakenly-called
    /// `endItem` without a matching `beginItem` is a compile
    /// error (no ItemCtx in scope).
    pub fn endItem(self: Ui, it: ItemCtx) void {
        self.itemSize(.{ it.rect.width, it.rect.height });
    }

    pub fn isMouseDragging(
        self: Ui,
        btn: MouseButton,
        lock_threshold: f32,
    ) bool {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return false;
        return runtime.input.isMouseDragging(s, btn, lock_threshold);
    }

    /// Cursor displacement since the most recent press of `button`,
    /// or `(0, 0)` if the button isn't held / threshold not yet crossed.
    /// Threshold semantics match `isMouseDragging` - pass `-1` for
    /// the imgui default.
    pub fn getMouseDragDelta(
        self: Ui,
        btn: MouseButton,
        lock_threshold: f32,
    ) Vec2 {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return .{ 0, 0 };
        const v: Vec2 = runtime.input.getMouseDragDelta(s, btn, lock_threshold);
        return .{ v[0], v[1] };
    }

    /// Reset the drag anchor for `button` to the current cursor
    /// position - subsequent `getMouseDragDelta` calls start fresh
    /// from here. Idiomatic for incremental drag handlers that
    /// consume the delta each frame.
    pub fn resetMouseDragDelta(self: Ui, btn: MouseButton) void {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return;
        runtime.input.resetMouseDragDelta(s, btn);
    }

    /// Current mouse position in screen pixels (same coordinate space as
    /// widget draw calls). Off-screen sentinel when there is no pointer.
    /// Used by custom widgets that need the raw cursor for hit-testing or
    /// zoom-around-cursor (e.g. the plot widget's wheel zoom).
    pub fn getMousePos(self: Ui) Vec2 {
        return self.ctx.input.mouse_pos;
    }

    /// Vertical mouse-wheel delta accumulated this frame. Positive =
    /// wheel rolled away from the user / scroll up. Zero when idle.
    pub fn getMouseWheel(self: Ui) f32 {
        return self.ctx.input.mouse_wheel_y;
    }

    /// Number of active touch points this frame (0 on a mouse-only
    /// device, 1 for a single finger, 2 during a pinch/two-finger
    /// gesture). Each browser pointer is fed in by `pointerId`, so this
    /// reflects real multi-touch on the web.
    pub fn getTouchPointCount(self: Ui) i32 {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return 0;
        return runtime.input.getTouchPointCount(s);
    }

    /// Screen position of the `index`-th active touch point, or the
    /// off-screen origin if there is no such touch.
    pub fn getTouchPos(self: Ui, index: usize) Vec2 {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return @splat(0);
        return runtime.input.getTouchPosition(s, @intCast(index));
    }

    /// This frame's pinch gesture, off the shared detector ticked in
    /// `beginFrameRaw`. `scale` is the frame-to-frame finger-distance
    /// ratio (>1 spreading / zoom in, <1 closing, 1.0 = no pinch — a
    /// no-op factor), `mid` the focal point to hold fixed while zooming.
    /// Widgets apply it to their own view; the detection is shared with
    /// every other pinch-zoom surface (fractals, 3D camera, ...).
    pub fn pinch(self: Ui) Pinch {
        return .{
            .scale = runtime.gestures.getGesturePinchScale(&self.ctx.gestures),
            .mid = runtime.gestures.getGesturePinchMid(&self.ctx.gestures),
        };
    }

    /// The gesture classified this frame by the shared detector (e.g.
    /// `.doubletap`, `.hold`, `.swipe_left`). `.none` when nothing fired.
    pub fn gesture(self: Ui) Gesture {
        return runtime.gestures.getGestureDetected(&self.ctx.gestures);
    }

    /// True if the current mouse position is inside the axis-aligned
    /// rectangle `(x0..x1, y0..y1)`. Edges are inclusive on near
    /// side, exclusive on far side (imgui semantics - half-open
    /// intervals so adjacent rects don't double-count). Coordinates
    /// are in the same space as the widget's draw calls.
    pub fn isMouseHoveringRect(
        self: Ui,
        x0: f32,
        y0: f32,
        x1: f32,
        y1: f32,
    ) bool {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return false;
        return runtime.input.isMouseHoveringRect(s, x0, y0, x1, y1);
    }

    // ---- Keyboard shortcuts (Phase B2)
    /// True if `chord` fires this frame. Edge-triggered on the key,
    /// level-triggered on modifiers. Pairs with conditional drawing
    /// to attach hotkey behavior to specific UI states:
    ///     if (ui.shortcut(.{.key =.s,.ctrl = true },.{})) save();
    ///     if (ui.shortcut(.{.key =.escape },.{})) cancel();
    /// Modifier strictness: Ctrl+S won't fire a bare-S shortcut.
    /// Pass `opts.repeat = true` to also fire on auto-repeat
    /// events (held key). See `runtime.input.KeyChord` and
    /// `ShortcutOpts` for the underlying types.
    pub fn shortcut(
        self: Ui,
        chord: KeyChord,
        opts: ShortcutOpts,
    ) bool {
        const s: *runtime.input.InputState = self.ctx.input_state orelse return false;
        return runtime.input.isKeyChordPressed(s, chord, opts);
    }

    // ---- Drawlist accessors (Phase B3)
    /// Get a handle on the BACKGROUND draw list - renders under all
    /// windows. Use for backdrops, grids, watermarks, debug overlays
    /// that should never occlude widgets.
    ///     const dl = ui.getBackgroundDrawList();
    ///     dl.addRectFilled(.{.x = 0,.y = 0,.width = 800,.height = 600 }, 0xFF1F2937);
    ///     dl.addText("debug",.{ 8, 8 }, 12, 0xFFE2E8F0);
    /// The handle is per-frame: it carries a pointer into the
    /// context's `background_dl` and the frame arena. Don't stash
    /// it across frames - the arena resets at endFrame and the
    /// pointer becomes invalid.
    pub fn getBackgroundDrawList(self: Ui) DrawListHandle {
        return .{
            .list = &self.ctx.background_dl,
            .gpa = self.ctx.frame_arena.allocator(),
        };
    }

    /// Get a handle on the FOREGROUND draw list - renders above all
    /// windows + popups (but below tooltips and drag previews).
    /// Use for watermarks, custom HUDs, debug crosshairs.
    ///     const dl = ui.getForegroundDrawList();
    ///     const red = colorToU32(.{ .r = 239, .g = 68, .b = 68, .a = 255 });
    ///     dl.addText("DEBUG BUILD", .{ 10, 10 }, 14, red);
    /// Colors are ColorU32 (0xAABBGGRR — R is the LOW byte), so prefer
    /// `colorToU32(Color)` over hand-packed hex. Handle is per-frame.
    pub fn getForegroundDrawList(self: Ui) DrawListHandle {
        return .{
            .list = &self.ctx.foreground_dl,
            .gpa = self.ctx.frame_arena.allocator(),
        };
    }

    /// Get a handle on the CURRENT WINDOW's draw list — the same list
    /// built-in widgets render into, so it is clipped to the window's
    /// content rect. Custom widgets that draw window-relative geometry
    /// (via beginItem/endItem) should use THIS rather than the
    /// foreground list, so their output is clipped to the window instead
    /// of spilling past its edges. Falls back to the foreground list
    /// when no window is current. Per-frame handle (arena-backed).
    pub fn getWindowDrawList(self: Ui) DrawListHandle {
        return .{
            .list = self.ctx.current_draw_list orelse &self.ctx.foreground_dl,
            .gpa = self.ctx.frame_arena.allocator(),
        };
    }

    /// Convenience: draw a filled rectangle on the current window's
    /// draw list using the `Color` struct (not the packed `ColorU32`
    /// the underlying DrawList takes). Equivalent to
    /// `u.getForegroundDrawList().addRectFilled(rect, colorToU32(c))`
    /// but shorter at the call site — extension widgets that want
    /// to paint inside their own rect reach for this constantly.
    /// No-op if no window is currently open.
    /// Convenience: draw a filled rectangle on the current window's
    /// draw list using the `Color` struct (not the packed `ColorU32`
    /// the underlying DrawList takes). Equivalent to
    /// `u.getForegroundDrawList().addRectFilled(rect, colorToU32(c))`
    /// but shorter at the call site — extension widgets that want
    /// to paint inside their own rect reach for this constantly.
    /// No-op if no window is currently open.
    pub fn fillRect(
        self: Ui,
        rect: Rectangle,
        c: Color,
    ) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.draw_list.addRectFilled(self.ctx.frame_arena.allocator(), rect, Color.toWire(c));
    }

    /// Convenience: stroked rectangle outline. See `fillRect`.
    pub fn strokeRect(
        self: Ui,
        rect: Rectangle,
        c: Color,
    ) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.draw_list.addRectOutline(self.ctx.frame_arena.allocator(), rect, Color.toWire(c));
    }

    // ---- item state queries
    /// True if the user is currently interacting with the most-
    /// recently-submitted widget (e.g. a slider being dragged, a
    /// button being held). raylib parity: `IsItemActive`.
    pub fn isItemActive(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_id != 0 and self.ctx.active_id == w.layout.last_item_id;
    }

    /// True if the most-recent widget became active this frame
    /// (transition from inactive → active). ImGui: `IsItemActivated`.
    pub fn isItemActivated(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_activated;
    }

    /// True if the most-recent widget transitioned from active → inactive
    /// this frame. ImGui: `IsItemDeactivated`.
    pub fn isItemDeactivated(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_deactivated;
    }

    /// True if the most-recent widget transitioned from active →
    /// inactive this frame AND the user edited its value at any point
    /// during the activation. Useful for "commit on release" patterns.
    /// ImGui: `IsItemDeactivatedAfterEdit`.
    pub fn isItemDeactivatedAfterEdit(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_deactivated_after_edit;
    }

    /// True if the user mutated the most-recent widget's value this
    /// frame (slider drag, checkbox toggle, inputText keystroke).
    /// ImGui: `IsItemEdited`.
    pub fn isItemEdited(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_edited;
    }

    /// True if the user clicked the most-recent widget this frame
    /// (mouse-down inside its rect on the same frame the click
    /// register). raylib parity: `IsItemClicked` (mouse button 0).
    pub fn isItemClicked(self: Ui) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        return w.layout.last_item_clicked;
    }

    /// True if the most-recent widget is currently keyboard-focused.
    /// Equivalent to `isItemActive` since there's no keyboard nav.
    pub fn isItemFocused(self: Ui) bool {
        return self.isItemActive();
    }

    /// Top-left of the most-recent widget's rectangle (screen px).
    /// raylib parity: `GetItemRectMin`.
    pub fn getItemRectMin(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return .{ w.layout.last_item_rect.x, w.layout.last_item_rect.y };
    }

    /// Bottom-right of the most-recent widget's rectangle (screen px).
    pub fn getItemRectMax(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return .{
            w.layout.last_item_rect.x + w.layout.last_item_rect.width,
            w.layout.last_item_rect.y + w.layout.last_item_rect.height,
        };
    }

    /// Width × height of the most-recent widget. ImGui: `GetItemRectSize`.
    pub fn getItemRectSize(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return .{ w.layout.last_item_rect.width, w.layout.last_item_rect.height };
    }

    /// Hashed ID of the most-recent widget (0 if none). Useful for
    /// debug + custom hover/active dispatch.
    pub fn getItemID(self: Ui) Id {
        const w: *Window = self.ctx.current_window orelse return 0;
        return w.layout.last_item_id;
    }

    // ---- setNextWindow* + window-state queries
    /// Set the position the next `window(...)` call applies. Default
    /// is "every frame" (matches ImGui's `ImGuiCond_Always`); pass
    /// `.{.once = true }` to apply only on first creation.
    /// ImGui: `SetNextWindowPos(pos, cond)`.
    pub fn setNextWindowPos(
        self: Ui,
        pos: Vec2,
        opts: SetNextWindowOpts,
    ) void {
        self.ctx.next_window_pos = pos;
        self.ctx.next_window_pos_always = !opts.once;
    }

    /// Set the size the next `window(...)` call applies. See
    /// `setNextWindowPos` for the once-vs-always distinction.
    /// ImGui: `SetNextWindowSize(size, cond)`.
    pub fn setNextWindowSize(
        self: Ui,
        size: Vec2,
        opts: SetNextWindowOpts,
    ) void {
        self.ctx.next_window_size = size;
        self.ctx.next_window_size_always = !opts.once;
    }

    /// Constrain the next window's size to `[min, max]` on its
    /// next open + every subsequent frame. Either bound can be
    /// `null` for "no limit". Useful for chrome-bounded windows
    /// (e.g. "this window must be at least 300×200 so its
    /// content fits").
    /// Constraints are also applied to user drag-resize gestures
    /// - the window simply can't grow/shrink past the bounds.
    /// Imgui: `SetNextWindowSizeConstraints` (`imgui.cpp:8664`).
    /// Imgui's custom-callback variant (for non-rectangular
    /// constraints) is deferred - see Step 1.x followup.
    pub fn setNextWindowSizeConstraints(
        self: Ui,
        min: ?Vec2,
        max: ?Vec2,
    ) void {
        self.ctx.next_window_size_min = min;
        self.ctx.next_window_size_max = max;
    }

    /// Set the position of an existing top-level window by name.
    /// No-op if no window with that name has been opened yet
    /// (matches imgui's `SetWindowPos(name,...)` which silently
    /// no-ops on FindWindowByName failure). Always-semantics
    /// applies unconditionally; ImGui's Cond gating is filed as a
    /// follow-up. Imgui: `imgui.cpp:8540`.
    pub fn setWindowPos(
        self: Ui,
        name: []const u8,
        pos: Vec2,
    ) void {
        const id: Id = hashStr(0, name);
        if (self.ctx.windows.get(id)) |w| {
            w.pos = pos;
        }
    }

    /// Set the size of an existing top-level window by name.
    /// See `setWindowPos` re: no-op when window absent. Marks
    /// `user_resized = true` so the window won't auto-fit growth
    /// after this call. Imgui: `imgui.cpp:8586`.
    pub fn setWindowSize(
        self: Ui,
        name: []const u8,
        size: Vec2,
    ) void {
        const id: Id = hashStr(0, name);
        if (self.ctx.windows.get(id)) |w| {
            w.size = size;
            w.user_resized = true;
        }
    }

    /// Raise an existing top-level window to focused. No-op if
    /// the window doesn't exist. Imgui: `imgui.cpp:12813`.
    pub fn setWindowFocus(self: Ui, name: []const u8) void {
        const id: Id = hashStr(0, name);
        if (self.ctx.windows.get(id)) |_| {
            self.ctx.focused_window_id = id;
        }
    }

    // Note: `setWindowCollapsed(name, bool)` from the plan is
    // deferred - zimr's Window struct doesn't yet have a
    // `collapsed` field nor the title-bar tap-toggle rendering
    // path that would back it. Filed: collapsed-window support
    // is its own multi-piece feature, not just an API addition.

    /// Override the next window's background alpha - 0 = fully
    /// transparent, 1 = opaque. Cleared after the window opens.
    /// ImGui: `SetNextWindowBgAlpha(alpha)`.
    pub fn setNextWindowBgAlpha(self: Ui, alpha: f32) void {
        self.ctx.next_window_bg_alpha = alpha;
    }

    /// Raise the next window to top of z-order on this frame.
    /// ImGui: `SetNextWindowFocus()`.
    pub fn setNextWindowFocus(self: Ui) void {
        self.ctx.next_window_focus = true;
    }

    /// True if the current window has focus, optionally including
    /// children / parents per `FocusedFlags`. Pass `.{}` for the
    /// default "exactly me" semantics; set
    /// `.root_and_child_windows` for "anything in my hierarchy."
    /// - `child_windows`: true if THIS window OR any descendant has
    ///   focus. Walks the focused window's parent chain.
    /// - `root_window`: walk to root of THIS window's parent chain
    ///   before testing.
    /// - `root_window` + `child_windows`: "is anything in my whole
    ///   hierarchy focused?" Docking-relevant combo.
    /// - `any_window`: true if ANY window in `UiContext` has focus.
    /// `no_popup_hierarchy` is accepted but no-op today (popup
    /// hierarchy linkage lands in a later phase). Always-false
    /// outside a window scope. ImGui: `IsWindowFocused(flags)`.
    pub fn isWindowFocused(self: Ui, opts: FocusedFlags) bool {
        const ctx: *UiContext = self.ctx;
        if (opts.any_window) {
            return ctx.focused_window_id != 0;
        }
        const me: *Window = ctx.current_window orelse return false;
        const focused_id: Id = ctx.focused_window_id;
        if (focused_id == 0) {
            return false;
        }
        if (opts.root_window) {
            const root: *Window = rootWindowOf(ctx, me);
            if (opts.child_windows) {
                return isWindowAncestor(ctx, root, focused_id);
            }
            return focused_id == root.id;
        }
        if (opts.child_windows) {
            return isWindowAncestor(ctx, me, focused_id);
        }
        return focused_id == me.id;
    }

    /// True if the mouse is over the current window's outer rect AND
    /// no later (higher-z) window covers that point, optionally
    /// including children / parents per `HoveredFlags`. Pass `.{}`
    /// for the default "exactly me" semantics.
    /// Hierarchy traversal (`child_windows`, `root_window`,
    /// `any_window`, `no_popup_hierarchy`) mirrors
    /// `isWindowFocused`. Implementation specifics:
    /// - `child_windows = true`: the mouse is over me OR any
    ///   descendant. Because children are nested inside the parent's
    ///   outer rect (zimr children don't escape parent bounds
    ///   enforced by the defensive clamp in `beginChildImpl`), this
    ///   is equivalent to `pointInRect(mouse, w.outer_rect)` for
    ///   non-overlapping descendants. For the FULL imgui semantics
    ///   we walk the hovered window's parent chain - handles the
    ///   case where a hovered popup-of-child should count as "child."
    /// The `allow_when_blocked_by_*`, `allow_when_overlapped_by_*`,
    /// `allow_when_disabled`, and `no_nav_override` flags are
    /// accepted but no-op today - see `HoveredFlags` field
    /// docstrings for when each lands.
    /// ImGui: `IsWindowHovered(flags)`.
    pub fn isWindowHovered(self: Ui, opts: HoveredFlags) bool {
        const ctx: *UiContext = self.ctx;
        if (opts.any_window) {
            return ctx.hovered_window_id != 0;
        }
        const me: *Window = ctx.current_window orelse return false;
        const hovered_id: Id = ctx.hovered_window_id;
        if (hovered_id == 0) {
            return false;
        }
        if (opts.root_window) {
            const root: *Window = rootWindowOf(ctx, me);
            if (opts.child_windows) {
                return isWindowAncestor(ctx, root, hovered_id);
            }
            return hovered_id == root.id;
        }
        if (opts.child_windows) {
            return isWindowAncestor(ctx, me, hovered_id);
        }
        return hovered_id == me.id;
    }

    /// Position of the current window's top-left in screen pixels.
    /// ImGui: `GetWindowPos()`.
    pub fn getWindowPos(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return w.pos;
    }

    /// Outer size of the current window (including title bar) in
    /// pixels. ImGui: `GetWindowSize()`.
    pub fn getWindowSize(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return w.size;
    }

    /// Width of the current window in pixels. Shortcut for
    /// `getWindowSize()[0]`.
    pub fn getWindowWidth(self: Ui) f32 {
        return self.getWindowSize()[0];
    }

    /// Height of the current window in pixels. Shortcut for
    /// `getWindowSize()[1]`.
    pub fn getWindowHeight(self: Ui) f32 {
        return self.getWindowSize()[1];
    }

    // ---- layout helpers
    /// Override the width of the next widget's primary input element.
    /// Cleared after the next widget consumes it. ImGui:
    /// `SetNextItemWidth(width)`.
    pub fn setNextItemWidth(self: Ui, width: f32) void {
        self.ctx.next_item_width = width;
    }

    /// Push an item width onto the stack - every subsequent widget
    /// reads it as their preferred width until popped. Stacks, so
    /// nested `pushItemWidth` calls compose. ImGui:
    /// `PushItemWidth(width)`.
    pub fn pushItemWidth(self: Ui, width: f32) void {
        self.ctx.item_width_stack.append(width) catch {};
    }

    /// Pop the most-recent `pushItemWidth` value. Symmetric - call
    /// exactly once per push. ImGui: `PopItemWidth()`.
    pub fn popItemWidth(self: Ui) void {
        _ = self.ctx.item_width_stack.pop();
    }

    /// Available content region from the current cursor to the right
    /// edge of the window's content area, and from the cursor to the
    /// bottom of the content area. Useful for sizing widgets that
    /// should fill remaining space. ImGui: `GetContentRegionAvail()`.
    pub fn getContentRegionAvail(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        // Use the CURRENT scope's bounds (`work_rect_max`), not the
        // top-level window's edges. When called from inside a
        // nested child, this gives the available space inside THAT
        // child - the right value for auto-sized layouts. Was the
        // companion bug to the spillover bug fixed.
        const cur: Vec2 = resolveCursor(w, self.ctx.style.item_spacing[0]);
        return .{
            @max(0, w.layout.work_rect_max[0] - cur[0]),
            @max(0, w.layout.work_rect_max[1] - cur[1]),
        };
    }

    /// Top-left of the window's content region, in WINDOW-LOCAL
    /// coordinates (relative to the window's top-left corner).
    /// Returns `.{0,0}` when called outside a window block. Matches
    /// ImGui's coordinate convention for this specific getter
    /// (window-local), even though most of zimr's cursor accessors
    /// use screen-space — the "window content region" framing
    /// implies window-local space, and matches what callers expect
    /// for fitting custom layouts into a window.
    /// ImGui: `GetWindowContentRegionMin()`.
    pub fn getWindowContentRegionMin(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return .{
            w.layout.origin[0] - w.pos[0],
            w.layout.origin[1] - w.pos[1],
        };
    }

    /// Bottom-right of the window's content region, in WINDOW-LOCAL
    /// coordinates (relative to the window's top-left corner).
    /// Returns `.{0,0}` when called outside a window block. Pair
    /// with `getWindowContentRegionMin` to compute the full content
    /// rect in window-local space.
    /// ImGui: `GetWindowContentRegionMax()`.
    pub fn getWindowContentRegionMax(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return .{
            w.layout.work_rect_max[0] - w.pos[0],
            w.layout.work_rect_max[1] - w.pos[1],
        };
    }

    /// Reserve a `size`-sized rectangle at the current cursor.
    /// Advances the layout cursor as if a widget that big had been
    /// drawn, and records it as the most-recent item so `isItem*`
    /// queries see a non-zero rect. Useful as a spacer or as a
    /// custom-render placeholder. ImGui: `Dummy(size)`.
    pub fn dummy(self: Ui, size: Vec2) void {
        const w: *Window = self.ctx.current_window orelse return;
        const at: Vec2 = resolveCursor(w, self.ctx.style.item_spacing[0]);
        const rect: Rectangle = .{
            .x = at[0],
            .y = at[1],
            .width = size[0],
            .height = size[1],
        };
        // Dummy is non-interactive; record id=0 so item-state
        // queries return safe defaults.
        w.layout.last_item_id = 0;
        w.layout.last_item_rect = rect;
        w.layout.last_item_hovered = false;
        markItemPost(self.ctx, w, false);
        advanceLayout(
            w,
            size,
            self.ctx.style.item_spacing,
            self.ctx.style.window_padding[0],
        );
    }

    /// Vertically align text to match the baseline of framed widgets
    /// (slider, drag, inputText) on the next line. When you intermix
    /// `text("Speed:")` with `slider(...)` on the same row via
    /// `sameLine`, the bare text otherwise sits flush at the cursor
    /// while the slider's frame extends both above and below - this
    /// padding compensates so the text reads centred-on-frame.
    /// ImGui: `AlignTextToFramePadding()`.
    pub fn alignTextToFramePadding(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        // Push cursor down by the frame's vertical padding so the
        // next text() call's baseline matches a framed widget's.
        w.layout.cursor_pos.y += self.ctx.style.frame_padding.y;
    }

    // ---- disabled scope
    /// Open a "disabled" scope. While the disabled stack is non-empty,
    /// widgets ignore input AND render with reduced alpha (matches
    /// `style.disabled_alpha`). Always pair with `endDisabled()`
    /// `defer` is the right way to spell that. Pass `false` to
    /// open a no-op scope (useful for conditional disabling without
    /// branching at every widget). ImGui: `BeginDisabled(bool disabled)`.
    pub fn beginDisabled(self: Ui, disabled: bool) void {
        // Push the marker first so endDisabled can pop symmetrically
        // regardless of whether THIS scope contributed.
        self.ctx.disabled_marker_stack.append(disabled) catch {};
        if (!disabled) {
            return;
        }
        // Outermost transition - save input + apply alpha + suppress
        // mouse buttons so widgets see "no clicks". Inner scopes
        // just bump the count.
        if (self.ctx.disabled_count == 0) {
            self.ctx.saved_input_when_disabled = self.ctx.input;
            self.ctx.input.mouse_left_clicked = false;
            self.ctx.input.mouse_left_down = false;
            self.ctx.input.mouse_left_released = false;
            self.ctx.input.mouse_right_clicked = false;
            self.ctx.input.mouse_middle_clicked = false;
            // Wheel + position deliberately preserved - users can
            // still scroll a disabled-content panel and hover-tooltips
            // continue to work.
            self.ctx.alpha_mul = self.ctx.style.disabled_alpha;
        }
        self.ctx.disabled_count += 1;
    }

    /// Close the matching `beginDisabled` scope.
    pub fn endDisabled(self: Ui) void {
        const was_disabled: bool = self.ctx.disabled_marker_stack.pop() orelse false;
        if (!was_disabled) {
            return;
        }
        if (self.ctx.disabled_count > 0) {
            self.ctx.disabled_count -= 1;
        }
        // Outermost-pop transition - restore input + alpha.
        if (self.ctx.disabled_count == 0) {
            if (self.ctx.saved_input_when_disabled) |saved| {
                self.ctx.input = saved;
            }
            self.ctx.saved_input_when_disabled = null;
            self.ctx.alpha_mul = 1.0;
        }
    }

    /// True if any active `beginDisabled` scope is currently open.
    /// Widgets check this to gate input + apply the alpha multiplier.
    pub fn isDisabled(self: Ui) bool {
        return self.ctx.disabled_count > 0;
    }

    /// Push an `ItemFlags` scope. Merged with the existing
    /// top-of-stack via `mergeItemFlags` (disabling AND repeating
    /// flags OR; auto_close_popups ANDs - disabling a parent's
    /// auto-close propagates).
    /// Use this for cross-widget behavior toggles. For example:
    ///     ui.pushItemFlag(.{.button_repeat = true });
    ///     defer ui.popItemFlag();
    ///     if (ui.button("+",.{})) increment(); // auto-repeats while held
    /// ImGui: `PushItemFlag(flag, enabled)` / `PopItemFlag()`.
    pub fn pushItemFlag(self: Ui, flags: ItemFlags) void {
        const ctx: *UiContext = self.ctx;
        const parent: ItemFlags = currentItemFlagsImpl(ctx);
        const merged: ItemFlags = mergeItemFlags(parent, flags);
        ctx.item_flags_stack.append(merged) catch {};
        // If this push turned.disabled true and we weren't already
        // disabled, mirror through beginDisabled's machinery so the
        // input-suppression + alpha-mul side effects fire too.
        if (merged.disabled and !parent.disabled) {
            ctx.saved_input_when_disabled = ctx.input;
            ctx.input.mouse_left_clicked = false;
            ctx.input.mouse_left_down = false;
            ctx.input.mouse_left_released = false;
            ctx.input.mouse_right_clicked = false;
            ctx.input.mouse_middle_clicked = false;
            ctx.alpha_mul = ctx.style.disabled_alpha;
            ctx.disabled_count += 1;
        } else if (merged.disabled) {
            ctx.disabled_count += 1;
        }
    }

    /// Pop the matching `pushItemFlag`. Always pair with a push;
    /// no-op when the stack is empty (defensive).
    pub fn popItemFlag(self: Ui) void {
        const ctx: *UiContext = self.ctx;
        const popped: ItemFlags = ctx.item_flags_stack.pop() orelse return;
        if (popped.disabled) {
            if (ctx.disabled_count > 0) {
                ctx.disabled_count -= 1;
            }
            // Outermost-pop transition - restore input + alpha.
            if (ctx.disabled_count == 0) {
                if (ctx.saved_input_when_disabled) |saved| {
                    ctx.input = saved;
                }
                ctx.saved_input_when_disabled = null;
                ctx.alpha_mul = 1.0;
            }
        }
    }

    /// The current effective item flags - the top of
    /// `item_flags_stack`, or all-default if empty. Widgets
    /// consult this for cross-widget behavior gates.
    pub fn currentItemFlags(self: Ui) ItemFlags {
        return currentItemFlagsImpl(self.ctx);
    }

    // ---- groups
    /// Open a "group" scope. Widgets submitted between `beginGroup`
    /// and `endGroup` lay out exactly as they normally would, but the
    /// group as a whole records a single bounding rect - so a
    /// follow-up `isItemHovered()` reports hover-over-the-whole-group
    /// rather than just hover-over-the-last-widget-in-it. Useful for
    /// labelled clusters that should act as one row. ImGui:
    /// `BeginGroup()`.
    pub fn beginGroup(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        // Resolve any pending sameLine BEFORE snapshotting so the
        // group's top-left is the resolved cursor.
        const at: Vec2 = resolveCursor(w, self.ctx.style.item_spacing[0]);
        const state: GroupState = .{
            .saved_cursor_pos = at,
            .saved_cursor_max = w.layout.cursor_max,
            .saved_line_height = w.layout.line_height,
        };
        self.ctx.group_stack.append(state) catch {};
        // Reset row-level state inside the group so a sameLine
        // outside the group doesn't accidentally cross into it.
        w.layout.line_height = 0;
        w.layout.cursor_pos = at;
    }

    /// Close the matching `beginGroup`. Computes the group's
    /// bounding rect, writes it into `last_item_*` for any
    /// follow-up `isItem*` query, and advances the parent cursor
    /// as if the group were a single item. ImGui: `EndGroup()`.
    pub fn endGroup(self: Ui) void {
        const w: *Window = self.ctx.current_window orelse return;
        const state: GroupState = self.ctx.group_stack.pop() orelse return;

        // Group rect: from the saved top-left to the max we extended
        // to. Use cursor_max which records the bottom-right of any
        // widget submitted during the group's lifetime.
        const min_corner: Vec2 = state.saved_cursor_pos;
        const max_corner: Vec2 = w.layout.cursor_max;
        const rect: Rectangle = .{
            .x = min_corner[0],
            .y = min_corner[1],
            .width = @max(0, max_corner[0] - min_corner[0]),
            .height = @max(0, max_corner[1] - min_corner[1]),
        };
        // The group has no stable id of its own - id=0 is fine
        // for hover-only queries; `markItemPost` skips the
        // activation-transition computation but still sets
        // last_item_hovered correctly via direct assignment below.
        w.layout.last_item_id = 0;
        w.layout.last_item_rect = rect;
        w.layout.last_item_hovered = pointInRect(self.ctx.input.mouse_pos, rect);
        markItemPost(self.ctx, w, false);

        // Restore row state to what was set BEFORE the group, then
        // advance the parent cursor so subsequent widgets sit BELOW
        // the group rect.
        w.layout.line_height = state.saved_line_height;
        advanceLayout(
            w,
            .{ rect.width, rect.height },
            self.ctx.style.item_spacing,
            self.ctx.style.window_padding[0],
        );
    }

    // ---- popups
    /// Mark a popup as "should be open from this frame onward".
    /// The popup itself doesn't render until matched with
    /// `beginPopup(str_id)`. `str_id` is hashed against the current
    /// window's id so two windows can have popups with identical
    /// labels without collision. Anchor position captured from the
    /// current mouse position - popup will appear at the cursor
    /// (matches ImGui's right-click-context-menu behaviour).
    /// ImGui: `OpenPopup(str_id)`.
    pub fn openPopup(self: Ui, str_id: []const u8) void {
        const parent_id: Id = if (self.ctx.current_window) |pw| pw.id else 0;
        const id: Id = hashStr(parent_id, str_id);
        // lint #4: double-open in same frame. If an entry
        // already exists AND its `opened_at_frame` matches the
        // current frame, the caller invoked openPopup twice this
        // frame for the same id — almost always a bug (the second
        // call's anchor wins; debug events fire twice).
        if (self.ctx.popup_open.get(id)) |existing| {
            if (existing.opened_at_frame == self.ctx.frame_count) {
                warnOnce(
                    @src(),
                    "ui.openPopup({s}): opened twice this frame (id={x})",
                    .{ str_id, id },
                );
            }
        }
        self.ctx.popup_open.put(self.ctx.gpa, id, .{
            .anchor = self.ctx.input.mouse_pos,
            .opened_at_frame = self.ctx.frame_count,
        }) catch {};
        debugLogPush(self.ctx, .popup_opened, "id={x} str='{s}'", .{ id, str_id });
    }

    /// True if the popup is currently open. ImGui: `IsPopupOpen(str_id)`.
    pub fn isPopupOpen(self: Ui, str_id: []const u8) bool {
        const parent_id: Id = if (self.ctx.current_window) |pw| pw.id else 0;
        const id: Id = hashStr(parent_id, str_id);
        return self.ctx.popup_open.contains(id);
    }

    /// Begin a popup scope. Returns true if the popup is open;
    /// caller must match a true return with `endPopup()`. Pattern:
    ///     if (ui.beginPopup("ctx_menu")) {
    ///         defer ui.endPopup();
    ///         if (ui.menuItem("Copy",.{})) {... }
    ///     }
    /// While submission is inside the popup scope, widgets render
    /// to the popup's draw list (which replays AFTER all regular
    /// windows in `endFrame`) and use the popup's persistent
    /// position + cursor. Click outside the popup OR a call to
    /// `closeCurrentPopup` dismisses it. ImGui: `BeginPopup(str_id)`.
    pub fn beginPopup(self: Ui, str_id: []const u8) bool {
        return openPopupScope(self.ctx, str_id);
    }

    /// Close the matching `beginPopup` scope. Symmetric - call
    /// EXACTLY once per `beginPopup` that returned true. `defer`
    /// is the recommended spelling. ImGui: `EndPopup()`.
    pub fn endPopup(self: Ui) void {
        closePopupScope(self.ctx);
    }

    /// Close the topmost popup currently open. Typically called
    /// from inside a popup body, e.g. on "Close" menu item or after
    /// a click that should dismiss. ImGui: `CloseCurrentPopup()`.
    pub fn closeCurrentPopup(self: Ui) void {
        const id: Id = self.ctx.popup_stack.top() orelse return;
        _ = self.ctx.popup_open.remove(id);
        debugLogPush(self.ctx, .popup_closed, "id={x} reason=user", .{id});
    }

    /// Open + begin a context-menu popup if the user right-clicked
    /// on the most-recently-submitted widget this frame. Returns
    /// true once the popup is open; pair with `endPopup()`.
    /// `str_id` defaults to `"context"` when null. Pattern:
    ///     _ = ui.button("Save",.{});
    ///     if (ui.beginPopupContextItem(null)) {
    ///         defer ui.endPopup();
    ///         if (ui.menuItem("Reset",.{})) state.reset();
    ///     }
    /// ImGui: `BeginPopupContextItem(str_id, flags)`.
    pub fn beginPopupContextItem(self: Ui, str_id: ?[]const u8) bool {
        const w: *Window = self.ctx.current_window orelse return false;
        const sid: []const u8 = str_id orelse "##ctx";
        // Auto-open: if last item is hovered AND user right-clicked.
        if (w.layout.last_item_hovered and self.ctx.input.mouse_right_clicked) {
            self.openPopup(sid);
        }
        return self.beginPopup(sid);
    }

    /// Begin a modal popup scope. Modals differ from regular popups
    /// in two ways: they render a fullscreen backdrop dim BEFORE
    /// the popup's chrome (visually disabling everything below), and
    /// they ignore click-outside-to-close - they must be dismissed
    /// explicitly via `closeCurrentPopup` OR by setting `*open_ptr`
    /// to false (typically wired to a Cancel/X button inside the
    /// modal body).
    ///     if (state.show_about) ui.openPopup("about");
    ///     if (ui.beginPopupModal("about", &state.show_about)) {
    ///         defer ui.endPopup();
    ///         ui.text("zimr v0.7.0",.{});
    ///         if (ui.button("OK",.{})) {
    ///             state.show_about = false;
    ///             ui.closeCurrentPopup();
    ///         }
    ///     }
    /// ImGui: `BeginPopupModal(name, p_open, flags)`.
    pub fn beginPopupModal(
        self: Ui,
        name: []const u8,
        open_ptr: ?*bool,
    ) bool {
        // Mark the popup-state as modal BEFORE entering scope so
        // the open helper renders the backdrop. Note: openPopup
        // must have been called separately; this just upgrades the
        // existing entry. If user passed open_ptr=false, dismiss.
        if (open_ptr) |p| {
            if (!p.*) {
                const parent_id: Id = if (self.ctx.current_window) |pw| pw.id else 0;
                const id: Id = hashStr(parent_id, name);
                _ = self.ctx.popup_open.remove(id);
                return false;
            }
        }
        const parent_id: Id = if (self.ctx.current_window) |pw| pw.id else 0;
        const id: Id = hashStr(parent_id, name);
        if (self.ctx.popup_open.getPtr(id)) |state| {
            state.is_modal = true;
        }
        return self.beginPopup(name);
    }

    // ---- main menu bar + menu + menuItem
    /// Begin the main menu bar at the top of the canvas. Returns
    /// true if the bar is active (rare false: not enough memory to
    /// allocate the synthetic bar window). Pair with
    /// `endMainMenuBar`. ImGui: `BeginMainMenuBar()`.
    ///     if (f.ui.beginMainMenuBar()) {
    ///         defer f.ui.endMainMenuBar();
    ///         if (f.ui.beginMenu("File")) {
    ///             defer f.ui.endMenu();
    ///             if (f.ui.menuItem("Open",.{.shortcut = "Ctrl+O" })) {}
    ///             if (f.ui.menuItem("Quit",.{})) state.should_quit = true;
    ///         }
    ///     }
    pub fn beginMainMenuBar(self: Ui) bool {
        return openMainMenuBar(self.ctx);
    }

    /// Close the main menu bar scope. Symmetric - call EXACTLY once
    /// per `beginMainMenuBar` that returned true. ImGui:
    /// `EndMainMenuBar()`.
    pub fn endMainMenuBar(self: Ui) void {
        closeMainMenuBar(self.ctx);
    }

    /// Begin a per-window menu bar - a horizontal strip pinned to the
    /// top of the current window, just below the title bar. Pair with
    /// `endMenuBar`. Returns true so the caller can use
    /// `if (ui.beginMenuBar()) { defer ui.endMenuBar();... }` with a
    /// uniform shape. Unlike `beginMainMenuBar` (which floats at the
    /// top of the canvas regardless of which window is active), this
    /// bar belongs to the window - drag the window, the bar drags
    /// with it. ImGui: `BeginMenuBar()` paired with a window opened
    /// via `Begin(name, p_open, ImGuiWindowFlags_MenuBar)`.
    ///     if (u.window("Tools",.{})) |w| {
    ///         defer w.close();
    ///         if (u.beginMenuBar()) {
    ///             defer u.endMenuBar();
    ///             if (u.beginMenu("File")) {
    ///                 defer u.endMenu();
    ///                 _ = u.menuItem("Save",.{.shortcut = "Ctrl+S" });
    ///             }
    ///         }
    ///         u.text("Window content below the bar...",.{});
    ///     }
    pub fn beginMenuBar(self: Ui) bool {
        return openWindowMenuBar(self.ctx);
    }

    /// Close the per-window menu bar scope. Symmetric - call
    /// EXACTLY once per `beginMenuBar` that returned true. ImGui:
    /// `EndMenuBar()`.
    pub fn endMenuBar(self: Ui) void {
        closeWindowMenuBar(self.ctx);
    }

    /// Begin a menu inside the main menu bar. Renders a clickable
    /// label in the bar; on click, opens a popup anchored just
    /// below the label. Returns true if the menu's popup is open
    /// (caller submits menu items, then calls `endMenu`). ImGui:
    /// `BeginMenu(label, enabled)`.
    pub fn beginMenu(self: Ui, label: []const u8) bool {
        return openMenu(self.ctx, label, true);
    }

    /// End a `beginMenu` scope. Symmetric - call EXACTLY once per
    /// `beginMenu` that returned true. ImGui: `EndMenu()`.
    pub fn endMenu(self: Ui) void {
        // The menu's popup is the topmost popup; close-popup-scope
        // matches the openMenu's beginPopup-style entry.
        closePopupScope(self.ctx);
    }

    /// A clickable menu entry. Returns true on the click frame.
    /// `opts.shortcut` is a display-only string (right-aligned in
    /// the row) - wiring the actual keyboard shortcut is the
    /// caller's responsibility.
    /// `opts.selected` shows a checkmark when true (toggle-style
    /// menu items). ImGui: `MenuItem(label, shortcut, selected,
    /// enabled)`.
    pub fn menuItem(
        self: Ui,
        label: []const u8,
        opts: MenuItemOpts,
    ) bool {
        return menuItemImpl(self.ctx, label, opts);
    }

    // ---- +: tab bars
    /// Begin a horizontal tab bar. Pair with `endTabBar`. Returns
    /// true so the caller can use `if (ui.beginTabBar(...,.{})) {... }`
    /// to skip the body when the bar can't open. ImGui:
    /// `BeginTabBar(str_id, flags)`.
    ///     if (f.ui.beginTabBar("settings_tabs",.{})) {
    ///         defer f.ui.endTabBar();
    ///         if (f.ui.beginTabItem("General", null,.{})) {
    ///             defer f.ui.endTabItem();
    ///             //... general settings widgets
    ///         }
    ///         if (f.ui.beginTabItem("Audio", null,.{})) {
    ///             defer f.ui.endTabItem();
    ///             //... audio settings widgets
    ///         }
    ///     }
    /// `opts: TabBarFlags` - pass `.{}` for default. See
    /// `TabBarFlags` for the flag inventory and current wired/
    /// no-op status.
    pub fn beginTabBar(
        self: Ui,
        str_id: []const u8,
        opts: TabBarFlags,
    ) bool {
        return openTabBar(self.ctx, str_id, opts);
    }

    /// Close the matching `beginTabBar` scope. Renders the
    /// bar→content separator line, GCs tabs the caller stopped
    /// submitting, advances the parent cursor past the tab bar.
    pub fn endTabBar(self: Ui) void {
        closeTabBar(self.ctx);
    }

    /// Begin a tab item inside the current tab bar. Returns true
    /// if THIS tab is the active one (caller submits widgets, then
    /// calls `endTabItem`).
    /// `open_ptr` (optional): non-null = render close-X. Clicking
    /// it (or middle-clicking the tab, unless suppressed) sets
    /// `*open_ptr = false` and removes the tab next frame.
    /// `opts: TabItemFlags` - pass `.{}` for default. Useful:
    /// `.unsaved_document` for `*` decoration, `.set_selected` to
    /// force-select. ImGui: `BeginTabItem(label, p_open, flags)`.
    pub fn beginTabItem(
        self: Ui,
        label: []const u8,
        open_ptr: ?*bool,
        opts: TabItemFlags,
    ) bool {
        return openTabItem(self.ctx, label, open_ptr, opts);
    }

    /// Close the matching `beginTabItem` scope. Symmetric - call
    /// EXACTLY once per `beginTabItem` that returned true. ImGui:
    /// `EndTabItem()`.
    pub fn endTabItem(self: Ui) void {
        closeTabItem(self.ctx);
    }

    // ---- list box
    /// Begin a fixed-height scrollable list box. Pair with
    /// `endListBox`. Inside the scope, the cursor lays out
    /// vertically as in any window; the box is clipped to its
    /// rect. Useful when you want a scrollable selection area
    /// inside a non-scrollable parent window.
    /// `size` interprets `0` as "auto" - width defaults to the
    /// available content region; height defaults to ~7 rows of
    /// the current font. ImGui: `BeginListBox(label, size)`.
    pub fn beginListBox(
        self: Ui,
        label: []const u8,
        size: Vec2,
    ) bool {
        return openListBox(self.ctx, label, size);
    }

    /// Close the matching `beginListBox` scope. Renders the
    /// border + advances the parent cursor. ImGui: `EndListBox()`.
    pub fn endListBox(self: Ui) void {
        closeListBox(self.ctx);
    }

    /// Convenience - render a complete labelled list box that
    /// shows `items` as selectable rows; `current.*` is the
    /// selected index. Returns true on the frame the user
    /// changes selection. ImGui: `ListBox(label, current, items,
    /// items_count, height_in_items)`.
    pub fn listBox(
        self: Ui,
        label: []const u8,
        current: *i32,
        items: []const []const u8,
    ) bool {
        return listBoxImpl(self.ctx, label, current, items);
    }

    // ---- additional widgets
    /// Progress indicator. `fraction` clamped to [0, 1]. `size`
    /// passes 0 in either axis to mean "auto" (full content width;
    /// 1-row default height). `overlay` is an optional centred
    /// caption - pass `""` for none. ImGui: `ProgressBar(fraction,
    /// size_arg, overlay)`.
    pub fn progressBar(
        self: Ui,
        fraction: f32,
        size: Vec2,
        overlay: []const u8,
    ) void {
        progressBarImpl(self.ctx, fraction, size, overlay);
    }

    /// Vertical slider - same semantics as `slider` but laid out
    /// top-to-bottom with `size.y` driving the bar's vertical span.
    /// Useful for mixers, level meters, etc. `value` is `*f32` or
    /// `*i32` (or any float/int via comptime dispatch). Returns
    /// true on the frame the value changes. ImGui:
    /// `VSliderFloat(label, size, v, v_min, v_max, format)` etc.
    pub fn vSlider(
        self: Ui,
        label: []const u8,
        size: Vec2,
        v: anytype,
        opts: SliderOpts,
    ) bool {
        return vSliderDispatch(self.ctx, label, size, v, opts);
    }

    /// Text-entry float input - like `inputText` but typed scalar.
    /// Renders the current value formatted via `opts.fmt`; user
    /// types a new number and presses Enter to commit. Invalid
    /// input is silently rejected on commit (value stays at last
    /// good). Returns true on the frame the value changes.
    /// Text-entry numeric input, generic over scalar or array. Pass
    /// `*f32` / `*i32` for a single value; pass `*[N]f32` / `*[N]i32`
    /// for a multi-component widget (N narrow boxes side-by-side).
    /// Returns true on any frame the value(s) changed. ImGui:
    /// `InputFloat` / `InputInt` / `InputFloat3` / `InputInt2`,
    /// collapsed to one generic function.
    ///     var pos: [3]f32 =.{ 0, 0, 0 };
    ///     _ = ui.input("position", &pos,.{});
    pub fn input(
        self: Ui,
        label: []const u8,
        v: anytype,
        opts: InputScalarOpts,
    ) bool {
        return inputDispatch(self.ctx, label, v, opts);
    }

    /// Text-entry float input. See `inputFloat`.
    /// ImGui: `InputFloat(label, v, step, step_fast, format,
    /// flags)`.
    pub fn inputFloat(
        self: Ui,
        label: []const u8,
        v: *f32,
        opts: InputScalarOpts,
    ) bool {
        return inputScalarImpl(self.ctx, label, v, f32, opts);
    }

    /// Text-entry int input. See `inputFloat`.
    pub fn inputInt(
        self: Ui,
        label: []const u8,
        v: *i32,
        opts: InputScalarOpts,
    ) bool {
        return inputScalarImpl(self.ctx, label, v, i32, opts);
    }

    /// Compact colour swatch - a clickable rectangle that displays
    /// a colour and shows the RGBA breakdown as a tooltip on hover.
    /// Returns true on the frame the user clicks the swatch.
    /// `value` is `*const [4]f32`, `*const [3]f32`, or `*const Color`.
    /// ImGui: `ColorButton(desc_id, col, flags, size)`.
    pub fn colorButton(
        self: Ui,
        desc_id: []const u8,
        v: anytype,
        size: Vec2,
    ) bool {
        return colorButtonDispatch(self.ctx, desc_id, v, size);
    }

    /// Clickable variant of `image`. Renders the texture as a
    /// button-sized rect (using the same `source` polymorphism as
    /// `image`) and returns true on the frame the user clicks it.
    /// `size: ?Vec2` - `null` means "use the texture's natural
    /// pixel dimensions". ImGui: `ImageButton(str_id, texture,
    /// size,...)`.
    pub fn imageButton(
        self: Ui,
        str_id: []const u8,
        source: anytype,
        size: ?Vec2,
    ) bool {
        return imageButtonImpl(self.ctx, str_id, source, size);
    }

    // ---- editStruct - comptime-reflective struct inspector
    /// Auto-build an editor UI for a struct via comptime reflection.
    /// Each field gets a widget chosen from its type:
    ///   - `bool` → `checkbox`
    ///   - `f32` / `f64` → `inputFloat` (or `slider` if opts gives min+max)
    ///   - `i32` / `u32` / … → `inputInt` (or `slider` if opts gives min+max)
    ///   - `[]const u8` → read-only `text` line (mutation TBD)
    ///   - nested struct → `treeNode` + recursive `editStruct`
    ///   - `enum` → `combo` over all enum values
    ///   - `[3]f32` / `[4]f32`→ `colorEdit3` / `colorEdit4` (special-cased)
    ///   - other arrays of float/int → one widget per element
    /// Returns true on the frame ANY field changed.
    ///     var s: MySettings =.{};
    ///     if (f.ui.editStruct(&s)) {
    ///         // re-apply settings...
    ///     }
    /// `editStruct` dispatches to `editStructOpts` with empty opts.
    pub fn editStruct(self: Ui, value_ptr: anytype) bool {
        return editStructImpl(self.ctx, value_ptr, .{});
    }

    /// Same as `editStruct` but accepts a per-field options struct.
    /// `opts` is a struct literal whose field names match the value's
    /// fields; each entry is itself a struct with optional members:
    ///   - `.min`, `.max` → switch to slider for numeric fields
    ///   - `.fmt` → format string (default per type)
    ///   - `.skip` → skip this field entirely
    ///   - `.label` → override the displayed label
    ///   - `.is_color` → force colorEdit dispatch for [3]/[4] f32 arrays
    /// Fields not mentioned in `opts` use defaults. Unknown opts
    /// fields are silently ignored - no @compileError - so a struct
    /// can grow without breaking existing call-sites.
    ///     f.ui.editStructOpts(&state,.{
    ///         .speed =.{.min = 0,.max = 100,.fmt = "{d:.1}" },
    ///         .iterations =.{.min = 1,.max = 1000 },
    ///         .password =.{.skip = true },
    ///     });
    pub fn editStructOpts(
        self: Ui,
        value_ptr: anytype,
        opts: anytype,
    ) bool {
        return editStructImpl(self.ctx, value_ptr, opts);
    }

    /// Reflection inspector (P1, AM-8). Wraps `editStruct` in a
    /// labeled, collapsible tree node — the right shape for a
    /// section header in a settings panel or dev-tools tab.
    ///     if (u.inspect("Style", &ctx.style)) {
    ///         // re-apply theme...
    ///     }
    /// The treeNode collapses by default; opens once on first click,
    /// remembers per-frame open state. When closed, returns false
    /// and skips the field iteration entirely (zero per-frame cost
    /// for collapsed inspectors).
    ///
    /// Use `editStruct(ptr)` directly when you DON'T want a header
    /// (e.g. when the inspector is already inside its own tab or
    /// window section), or `inspectWithAttrs` for per-field hints.
    pub fn inspect(
        self: Ui,
        label: []const u8,
        value_ptr: anytype,
    ) bool {
        if (!self.treeNode(label, .{})) {
            return false;
        }
        defer self.treePop();
        return editStructImpl(self.ctx, value_ptr, .{});
    }

    /// Labeled variant of `editStructOpts`. Same per-field opts
    /// shape; wraps in a collapsible tree node like `inspect`.
    ///     if (u.inspectWithAttrs("Settings", &s,.{
    ///         .speed =.{.min = 0,.max = 100 },
    ///         .password =.{.skip = true },
    ///     })) {... }
    pub fn inspectWithAttrs(
        self: Ui,
        label: []const u8,
        value_ptr: anytype,
        opts: anytype,
    ) bool {
        if (!self.treeNode(label, .{})) {
            return false;
        }
        defer self.treePop();
        return editStructImpl(self.ctx, value_ptr, opts);
    }

    // ---- editArrayList - list editor with add/remove
    /// Auto-build an editor UI for a `std.ArrayList(T)`. Renders a
    /// collapsible header with the count + an "Add" button that
    /// appends a default-initialized `T`, then one row per element
    /// with a "Remove" button. Each element is dispatched through
    /// the same field-type matrix as `editStruct` (struct → recursive
    /// editor, primitive → matching widget, etc.).
    /// Pass a pointer to your `std.ArrayList(T)`. The list's
    /// allocator is read from `ctx.gpa` (the same allocator used by
    /// every other persistent UI structure). Returns true on the
    /// frame ANY field changes OR the list grows/shrinks.
    ///     var enemies: std.ArrayList(Enemy) =.empty;
    ///     defer enemies.deinit(gpa);
    ///     //... later, in update():
    ///     if (f.ui.editArrayList("Enemies", &enemies)) {
    ///         // re-sort spatial index, etc.
    ///     }
    pub fn editArrayList(
        self: Ui,
        label: []const u8,
        list_ptr: anytype,
    ) bool {
        return editArrayListImpl(self.ctx, label, list_ptr);
    }

    /// Same as `editArrayList` but accepts the same per-field opts
    /// struct as `editStructOpts`. Forwarded to each element when
    /// the element type is a struct so per-element fields can be
    /// constrained (e.g. `.health =.{.min = 0,.max = 100 }`).
    pub fn editArrayListOpts(
        self: Ui,
        label: []const u8,
        list_ptr: anytype,
        opts: anytype,
    ) bool {
        return editArrayListImplOpts(self.ctx, label, list_ptr, opts);
    }

    // ---- Style editor + layout persistence
    /// Build a complete editor for the active `Style` via
    /// `editStruct`. Every Color slot becomes a `colorEdit` widget,
    /// every Vec2 becomes a side-by-side pair of inputFloats,
    /// every f32 becomes an inputFloat. Returns true on the frame
    /// any style field changed.
    ///     if (f.ui.styleEditor()) {
    ///         // restyle just happened - bump theme rev counter etc.
    ///     }
    pub fn styleEditor(self: Ui) bool {
        return editStructImpl(self.ctx, self.style(), .{});
    }

    /// Serialize all currently-known windows' positions, sizes,
    /// scroll states, and user-resized flags into a binary buffer
    /// the caller owns and frees. Format is tagged with magic +
    /// version so future format changes can be detected. Pair with
    /// `applyLayout(bytes)` to restore. Useful for "remember
    /// window layout across launches".
    ///     const buf = try f.ui.serializeLayout(gpa);
    ///     defer gpa.free(buf);
    ///     try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = "layout.bin", .data = buf });
    pub fn serializeLayout(self: Ui, gpa: Allocator) ![]u8 {
        return serializeLayoutImpl(self.ctx, gpa);
    }

    /// Restore window positions/sizes from a buffer previously
    /// produced by `serializeLayout`. Unknown magic / version is
    /// rejected with `error.LayoutFormatMismatch`. Window IDs not
    /// present in the current `ctx.windows` map create new entries
    /// with the saved geometry; IDs that DO exist get their pos /
    /// size / scroll_y / user_resized fields overwritten. Order
    /// of windows in the buffer doesn't matter. Safe to call
    /// before any window is submitted.
    pub fn applyLayout(self: Ui, bytes: []const u8) !void {
        return applyLayoutImpl(self.ctx, bytes);
    }

    // ---- keyboard nav + showDemoWindow
    /// Show the canonical zimr/imgui demo window. One call walks
    /// every widget category the port supports, organized into a
    /// tab bar (Widgets / Layout / Style / About). Useful as a
    /// living showcase, a smoke-test that every widget at least
    /// submits without panic, and a copy-paste reference for users
    /// learning the API.
    /// `state` carries the persistent values the demo's widgets
    /// drive - sliders, checkboxes, text buffers, etc. Pass a
    /// pointer to a `DemoState` initialized with `.{}` (all defaults).
    /// `p_open`, when non-null, is read at the top: if false, the
    /// demo doesn't render. When the user clicks the window's close
    /// button (not yet implemented for general windows), `*p_open`
    /// becomes false. Pass `null` to render unconditionally.
    ///     var demo: ui.DemoState =.{};
    ///     //... in update():
    ///     f.ui.showDemoWindow(&demo, null);
    pub fn showDemoWindow(
        self: Ui,
        state: *DemoState,
        p_open: ?*bool,
    ) void {
        showDemoWindowImpl(self, state, p_open);
    }

    /// Submit a tooltip to be drawn above all other content at end
    /// of frame. Position is the current mouse position +
    /// (16, 16) offset. Last call this frame wins.
    pub fn setTooltip(
        self: Ui,
        comptime fmt: []const u8,
        args: anytype,
    ) void {
        var tt = TooltipData{
            .pos = .{ self.ctx.input.mouse_pos[0] + 16, self.ctx.input.mouse_pos[1] + 16 },
        };
        const formatted: []u8 = bufPrint(&tt.text_buf, fmt, args) catch tt.text_buf[0..0];
        tt.text_len = formatted.len;
        self.ctx.pending_tooltip = tt;
    }

    /// Open a tooltip block. Widgets submitted between
    /// `beginTooltip` and `endTooltip` are routed into the
    /// tooltip's own draw list, which renders on top of
    /// everything else (above popups) at endFrame.
    /// Position is anchored at the current mouse position +
    /// (16, 16) offset. The tooltip's size auto-fits its
    /// content. Pattern:
    ///     if (ui.isItemHovered(.{})) {
    ///         ui.beginTooltip();
    ///         ui.text("Detailed info...",.{});
    ///         _ = ui.slider("preview", &p.x,.{});
    ///         ui.endTooltip();
    ///     }
    /// Multiple `beginTooltip` / `endTooltip` pairs in one frame
    /// are not supported - the last `beginTooltip` wins (its
    /// contents replace any previous tooltip block).
    pub fn beginTooltip(self: Ui) void {
        beginTooltipImpl(self.ctx);
    }

    /// Close the tooltip block opened by `beginTooltip`. Safe to
    /// call when no tooltip is open (no-op).
    pub fn endTooltip(self: Ui) void {
        endTooltipImpl(self.ctx);
    }

    // ---- Helpers exposed for later widget impls
    /// Current widget anchor in screen pixels. In the post-308.5
    /// layout model, sameLine writes directly to `cursor_pos`, so
    /// this is just a read; the function form is retained for
    /// callsite readability.
    pub fn cursorScreenPos(self: Ui) Vec2 {
        const w: *Window = self.ctx.current_window orelse return .{ 0, 0 };
        return resolveCursor(w, self.ctx.style.item_spacing[0]);
    }

    // ============================================================================
    // Phase A1 - Window scroll API + clipboard
    // ============================================================================

    /// Current vertical scroll offset of the active window in
    /// pixels. Returns 0 if no window is active. ImGui:
    /// `GetScrollY()`.
    pub fn getScrollY(self: Ui) f32 {
        const w: *Window = self.ctx.current_window orelse return 0;
        return w.scroll_y;
    }

    /// Maximum allowed scroll for the active window - clamp upper
    /// bound enforced by end-of-frame. Reads last frame's
    /// computed value; for mid-frame programmatic scroll the
    /// previous frame's max is the best estimate (subsequent
    /// frames converge). Returns 0 if no window is active or if
    /// content fits without overflow. ImGui: `GetScrollMaxY()`.
    pub fn getScrollMaxY(self: Ui) f32 {
        const w: *Window = self.ctx.current_window orelse return 0;
        return w.scroll_max_y;
    }

    /// Set the vertical scroll position directly. Clamped to
    /// `[0, scroll_max_y]`. Useful for "remember scroll position"
    /// or "jump to top" buttons. ImGui: `SetScrollY(scroll_y)`.
    pub fn setScrollY(self: Ui, y: f32) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.scroll_y = @max(0, @min(y, w.scroll_max_y));
    }

    // ---- X-axis scroll accessors --------------------

    /// Current horizontal scroll offset of the active window in
    /// pixels. Returns 0 if no window is active OR if the window
    /// hasn't opted into `WindowFlags.horizontal_scrollbar` (scroll_x
    /// is forced to 0 in `closeWindow` when the flag is unset).
    /// Imgui: `GetScrollX()`.
    pub fn getScrollX(self: Ui) f32 {
        const w: *Window = self.ctx.current_window orelse return 0;
        return w.scroll_x;
    }

    /// Maximum allowed horizontal scroll for the active window.
    /// Reads last frame's computed value (same convergence semantics
    /// as `getScrollMaxY`). Returns 0 if no window is active, the
    /// horizontal-scrollbar flag is unset, or content fits without
    /// X-overflow. Imgui: `GetScrollMaxX()`.
    pub fn getScrollMaxX(self: Ui) f32 {
        const w: *Window = self.ctx.current_window orelse return 0;
        return w.scroll_max_x;
    }

    /// Set the horizontal scroll position directly. Clamped to
    /// `[0, scroll_max_x]`. No-op when the window hasn't opted into
    /// `WindowFlags.horizontal_scrollbar` (the next `closeWindow`
    /// would clobber any write to scroll_x back to 0 anyway).
    /// Imgui: `SetScrollX(scroll_x)`.
    pub fn setScrollX(self: Ui, x: f32) void {
        const w: *Window = self.ctx.current_window orelse return;
        w.scroll_x = @max(0, @min(x, w.scroll_max_x));
    }

    /// Adjust scroll so the current cursor position appears at
    /// `ratio` within the visible vertical area:
    ///   - 0.0 - cursor at the TOP of visible area
    ///   - 0.5 - cursor CENTERED
    ///   - 1.0 - cursor at the BOTTOM
    /// Idiomatic use: at end-of-frame in a log viewer, call
    /// `setScrollHereY(1.0)` to auto-scroll-to-bottom. ImGui:
    /// `SetScrollHereY(center_y_ratio = 0.5)`.
    pub fn setScrollHereY(self: Ui, ratio: f32) void {
        const w: *Window = self.ctx.current_window orelse return;
        const ctx: *UiContext = self.ctx;
        // Recover content-coord y of the cursor (cursor_pos is
        // already screen-coord and pre-shifted by scroll_y, so
        // undo the shift).
        const inner_origin_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
        const cursor_content_y: f32 = w.layout.cursor_pos[1] + w.scroll_y - inner_origin_y;
        const viewport_h: f32 = @max(0, w.size[1] - ctx.style.title_bar_height - ctx.style.window_padding[1]);
        const desired: f32 = cursor_content_y - ratio * viewport_h;
        w.scroll_y = @max(0, @min(desired, w.scroll_max_y));
    }

    /// Like `setScrollHereY` but with an explicit content-area
    /// y-coordinate instead of the cursor. `local_y` is relative
    /// to the window's content-area top-left. ImGui:
    /// `SetScrollFromPosY(local_y, center_y_ratio = 0.5)`.
    pub fn setScrollFromPosY(
        self: Ui,
        local_y: f32,
        ratio: f32,
    ) void {
        const w: *Window = self.ctx.current_window orelse return;
        const ctx: *UiContext = self.ctx;
        const viewport_h: f32 = @max(0, w.size.y - ctx.style.title_bar_height - ctx.style.window_padding.y);
        const desired: f32 = local_y - ratio * viewport_h;
        w.scroll_y = @max(0, @min(desired, w.scroll_max_y));
    }

    /// Write text to the system clipboard. Sync from caller's
    /// perspective; the underlying browser API
    /// (`navigator.clipboard.writeText`) is async fire-and-forget
    /// and may silently fail if the document lacks focus or the
    /// user hasn't granted permission. On the host build this is
    /// a no-op. ImGui: `SetClipboardText(text)`.
    pub fn setClipboardText(self: Ui, s: []const u8) void {
        _ = self;
        @import("runtime.zig").core.setClipboardText(s);
    }

    /// Return the most recently read clipboard text, or "" if no
    /// successful read has occurred yet. Reads are inherently
    /// async on the web platform - the imgui-style sync getter is
    /// a stub for surface parity; the actual async fetch happens
    /// via `runtime.getClipboardTextAsync()` with a polling
    /// handle. Long-form: store the result of the async read in
    /// `ctx.clipboard_cache_buf` and this getter returns that.
    /// Today: empty (no caching wired yet - that lands with the
    /// `InputTextCallbackData` paste plumbing in Phase A4).
    /// ImGui: `GetClipboardText() const char*`.
    pub fn getClipboardText(self: Ui) []const u8 {
        return self.ctx.clipboard_cache[0..self.ctx.clipboard_cache_len];
    }

    // ============================================================================
    // Phase A2 - ImGuiListClipper: virtualized rendering for huge lists
    // ============================================================================

    /// Open a virtualized clipper scope. Use when rendering long
    /// item lists (1000+ rows) where most rows would fall outside
    /// the visible viewport. Without a clipper, every row's
    /// widget submission cost is paid even for rows the user
    /// can't see.
    /// Idiomatic use:
    ///     var clip = u.clipper(line_count, line_height);
    ///     while (clip.step()) |range| {
    ///         var i: usize = range.start;
    ///         while (i < range.end): (i += 1) {
    ///             u.text("Row {d}: {s}",.{ i, rows[i] });
    ///         }
    ///     }
    /// **Precondition: `item_height` must be the rendered height
    /// of one item - including item_spacing.y.** The clipper
    /// computes the visible row range arithmetically from
    /// `(viewport_top - cursor.y) / item_height`; a wrong height
    /// silently miscalculates the range (renders the wrong rows
    /// or duplicates rows when the user scrolls). For variable-
    /// height rows the imgui idiom is "measure on first frame,
    /// virtualize on second frame" - not implemented in A2 (see
    /// `Clipper.measure_pending` field reserved for that).
    /// `item_count == 0` returns a clipper that completes
    /// immediately (zero step iterations). Negative item_height
    /// is clamped to 1 to avoid division-by-zero.
    /// ImGui: `ImGuiListClipper` (constructor + `Begin`).
    pub fn clipper(
        self: Ui,
        item_count: usize,
        item_height: f32,
    ) Clipper {
        return Clipper.init(self.ctx, item_count, item_height);
    }
};

// ============================================================================
// Options structs (per-widget configuration)
// ============================================================================

/// Per-channel value display format used by `colorEdit` for the
/// text overlaid on each channel slider.  Mirrors imgui's
/// `_DisplayRGB` / `_DisplayHex` / `_Float` / `_Uint8` flag pairs;
/// zimr collapses the orthogonal axes into a single enum because
/// HSV display + non-RGB picker variants belong to a later P10
/// sub-turn (no need for an HSV float vs HSV int distinction
/// until HSV display lands).
///
/// Mirrors imgui flags: `_Float` (default), `_Uint8`, `_DisplayHex`.
pub const ColorEditDisplayFormat = enum {
    /// Per-channel 0.0–1.0 with `{d:.2}` precision (e.g. "0.50").
    /// Default — matches the conventional "shader uniform" mental
    /// model where channels are floats.
    float,
    /// Per-channel 0–255 (e.g. "128").  The web/CSS / 8-bit image
    /// mental model.  Computed as `@round(v * 255)`.
    int_0_255,
    /// Single combined hex string overlaid on the swatch
    /// (e.g. "#80FF8040" for RGBA).  Per-channel sliders show no
    /// value text in this mode — the hex IS the value display.
    /// Format: `#RRGGBB` for 3 channels, `#RRGGBBAA` for 4.
    hex,
};

pub const ColorEditOpts = struct {
    /// DEPRECATED.  Was intended for custom slider-value
    /// precision, but `std.fmt.bufPrint` requires a comptime
    /// format string and `[]const u8` opts fields are runtime —
    /// so the field was silently ignored.  Kept for source
    /// compatibility; new code should not set it.  Value text is
    /// always `{d:.2}` for `.float` and `{d}` for `.int_0_255`.
    /// Custom precision would need a separate mechanism (e.g.
    /// an enum of allowed precisions).
    fmt: []const u8 = "{d:.2}",
    /// Inline width override (default: auto-fit to row).
    width: f32 = 0,
    /// How channel values are rendered on top of the sliders.
    /// See `ColorEditDisplayFormat` for the regime details.
    display_format: ColorEditDisplayFormat = .float,
};

/// Format an RGB triplet (or RGBA quad) into a `#RRGGBB[AA]`
/// string.  Caller provides the destination buffer (must be at
/// least 9 bytes for RGBA, 7 for RGB).  Returns the populated
/// slice.  Channels are clamped to 0.0–1.0 before conversion so
/// out-of-range inputs round-trip predictably.
///
/// Pure function — no UI state touched — so it's testable in
/// isolation and reusable from non-widget code (e.g. logging,
/// serialization).
pub fn formatColorRgbHex(
    rgba: [4]f32,
    has_alpha: bool,
    out: []u8,
) []const u8 {
    const min_needed: usize = if (has_alpha) 9 else 7;
    if (out.len < min_needed) {
        return out[0..0];
    }
    const r: u8 = @round(clamp(rgba[0], 0.0, 1.0) * 255);
    const g: u8 = @round(clamp(rgba[1], 0.0, 1.0) * 255);
    const b: u8 = @round(clamp(rgba[2], 0.0, 1.0) * 255);
    out[0] = '#';
    _ = bufPrint(out[1..3], "{X:0>2}", .{r}) catch return out[0..0];
    _ = bufPrint(out[3..5], "{X:0>2}", .{g}) catch return out[0..0];
    _ = bufPrint(out[5..7], "{X:0>2}", .{b}) catch return out[0..0];
    if (has_alpha) {
        const a: u8 = @round(clamp(rgba[3], 0.0, 1.0) * 255);
        _ = bufPrint(out[7..9], "{X:0>2}", .{a}) catch return out[0..0];
        return out[0..9];
    }
    return out[0..7];
}

/// Convert a 0.0–1.0 channel value to its 0–255 integer
/// representation with imgui-compatible rounding (round-half-
/// away-from-zero).  Out-of-range inputs clamp first.
///
/// Pure fn, used by the `.int_0_255` display path.
pub fn colorChannelToInt255(v: f32) u8 {
    return @round(clamp(v, 0.0, 1.0) * 255);
}

/// Which on-screen layout `colorPicker` uses for the hue selector.
pub const ColorPickerLayout = enum {
    /// Bar: horizontal hue gradient stacked above a saturation/
    /// value box. Compact; fits in a standard widget row stack.
    bar,
    /// Wheel: hue ring around a saturation/value triangle. Larger
    /// footprint, looks more painterly.
    wheel,
};

pub const ColorPickerOpts = struct {
    /// Bar or wheel hue selector. Default `.bar` matches the
    /// imgui default; switch to `.wheel` for a more visual layout.
    layout: ColorPickerLayout = .bar,
    /// Edit alpha channel too (renders an extra alpha bar / slider).
    /// Only relevant for `*[4]f32` / `*Color`; ignored for `*[3]f32`.
    alpha: bool = true,
    /// Side length (pixels) of the SV box / wheel diameter.
    /// Zero = sensible default of 180.
    size: f32 = 0,
};

/// Table-level sizing policy. Sets the DEFAULT sizing for any
/// column whose `tableSetupColumn` opts don't specify a per-column
/// override. Mirrors imgui's `ImGuiTableFlags_Sizing*` family
/// (imgui.h:2141-2144).
///
/// All four modes rely on `width_auto_seen` — the maximum content
/// width measured during the PREVIOUS frame, persisted across
/// frames in `UiContext.table_width_auto_cache`. The first frame
/// a table is submitted has no measurement yet, so each column
/// falls back to `fallbackColumnWidth(style.font_size)` (≈ 6em).
/// One frame of layout lag is intrinsic — zimr is single-pass and
/// cannot know a column's natural width before its cells render.
pub const TableSizing = enum {
    /// Every stretch column shares the available width equally.
    /// Each gets `remaining / count_stretch` pixels unless the
    /// caller passed a per-column `weight`, which wins.
    /// Matches imgui's `SizingStretchSame`.
    /// This is the default — preserves zimr's pre-behavior.
    stretch_same,
    /// Stretch columns share the available width proportionally
    /// to their measured content width — a column with twice as
    /// much content gets twice the share. Per-column `weight`
    /// (if supplied) wins.
    /// Matches imgui's `SizingStretchProp`.
    stretch_prop,
    /// Each column is sized to its own measured content width.
    /// Per-column `width` (if supplied) wins.
    /// Matches imgui's `SizingFixedFit`.
    fixed_fit,
    /// All fixed columns get the same width — the maximum of any
    /// fixed column's measured content width. Per-column `width`
    /// (if supplied) wins.
    /// Matches imgui's `SizingFixedSame`.
    fixed_same,
};

pub const TableOpts = struct {
    /// P8.4: table-level sizing policy. Sets the
    /// default sizing for every column that doesn't override via
    /// `tableSetupColumn`'s `sizing` opt. The four modes are
    /// content-fit variants — see `TableSizing` for what each
    /// mode does and the first-frame fallback caveat.
    ///
    /// Default `.stretch_same` — equal share across all columns
    /// — preserves zimr's pre-behavior. Existing tables
    /// that don't set this field keep their old layout.
    /// Mirrors imgui's `ImGuiTableFlags_Sizing*` family
    /// (imgui.h:2141-2144).
    sizing: TableSizing = .stretch_same,

    /// When true, draw 1px borders. Default true; set false for
    /// a cleaner data grid without lines. P8.2: this
    /// is now a MASTER flag that AND-gates the four granular
    /// `borders_*` flags below — `borders = false` disables ALL
    /// border drawing regardless of the granular flags' values.
    /// For finer-grained control, leave `borders = true` (the
    /// default) and selectively disable individual `borders_*`
    /// flags. Backward compat: setting `borders = false` works
    /// exactly as it did pre-P8.2.
    borders: bool = true,

    /// P8.2: horizontal interior lines — the per-row separator
    /// between data rows AND the line between the header row and
    /// the first data row. Gated by `borders`. Default true.
    /// ImGui equivalent: `ImGuiTableFlags_BordersInnerH`.
    borders_inner_h: bool = true,

    /// P8.2: horizontal exterior lines — the top and bottom
    /// edges of the table's outer rect. Gated by `borders`.
    /// Default true.
    /// ImGui equivalent: `ImGuiTableFlags_BordersOuterH`.
    borders_outer_h: bool = true,

    /// P8.2: vertical interior lines — the column dividers and
    /// the per-cell right edges in the header row. Gated by
    /// `borders`. Default true.
    /// ImGui equivalent: `ImGuiTableFlags_BordersInnerV`.
    borders_inner_v: bool = true,

    /// P8.2: vertical exterior lines — the left and right edges
    /// of the table's outer rect. Gated by `borders`. Default
    /// true.
    /// ImGui equivalent: `ImGuiTableFlags_BordersOuterV`.
    borders_outer_v: bool = true,

    /// Horizontal padding inside each cell. Default 4px.
    cell_padding_x: f32 = 4,
    /// Vertical padding inside each cell. Default 2px.
    cell_padding_y: f32 = 2,

    /// P8.5: explicit horizontal-scroll opt-in. When
    /// true, the table allows the total column width to exceed
    /// `outer_width`; content past `outer_width` is clipped on
    /// the right side, and the column-overflow lint is
    /// suppressed (overflow is intentional, not a bug).
    ///
    /// Default false. Mirrors imgui's `ImGuiTableFlags_ScrollX`
    /// (imgui.h:2157). Like imgui's flag, this requires
    /// `outer_width > 0` to be meaningful — when `outer_width`
    /// is 0 (size-to-content), there's nothing to scroll past.
    ///
    /// NOTE: this turn lands the flag, the clip
    /// behavior, and the lint suppression. Actual horizontal
    /// scrolling INPUT (shift-wheel, drag) and a horizontal
    /// scrollbar UI are deferred to a follow-up turn. Callers
    /// who set `scroll_x = true` today get a wider, clipped
    /// table but can't yet pan to see the right side.
    scroll_x: bool = false,

    /// P8.5: explicit vertical-scroll opt-in. When
    /// true (or when `outer_height > 0`, kept for back-compat),
    /// vertical scrolling is enabled. Mirrors imgui's
    /// `ImGuiTableFlags_ScrollY` (imgui.h:2158).
    ///
    /// Pre-zimr enabled vertical scrolling implicitly
    /// whenever `outer_height > 0`. That path is preserved —
    /// existing callers don't need to set this flag. The flag
    /// is provided so new callers can express intent explicitly:
    /// "this table is meant to scroll vertically" reads better
    /// than "this table has a fixed height (which incidentally
    /// makes it scroll)".
    scroll_y: bool = false,

    /// P8.3: when true, the LEFTMOST column's content
    /// is flush against the table's outer-left edge — no
    /// `cell_padding_x` gap between the outer border and the
    /// first cell's content. Default false (legacy behaviour
    /// preserved: column 0 gets the same `cell_padding_x` left
    /// gap as every other column).
    /// zimr applies cell_padding_x as LEFT-side margin only;
    /// the right side has no explicit padding today, so the
    /// rightmost column's outer-right gap is unaffected by this
    /// flag. ImGui equivalent: `ImGuiTableFlags_NoPadOuterX`
    /// (with the caveat that ImGui's model has symmetric L/R
    /// padding; zimr's is L-only).
    no_pad_outer_x: bool = false,

    /// P8.3: when true, columns 1..N-1 (every column EXCEPT the
    /// first) have no `cell_padding_x` gap on their left side —
    /// content is flush against the column divider line on the
    /// left. Default false. Useful for tightly-packed grids
    /// where the column divider IS the visual break and the
    /// padding gap would feel excessive.
    /// ImGui equivalent: `ImGuiTableFlags_NoPadInnerX`.
    no_pad_inner_x: bool = false,
    /// Override the table's outer width. Zero = fill remaining
    /// row width in the parent window.
    outer_width: f32 = 0,
    /// Height cap for the table (pixels). When non-zero AND the
    /// submitted rows' total height exceeds this, the table
    /// becomes vertically scrollable - wheel-over scrolls within
    /// the cap, content beyond the cap is clipped. Zero (the
    /// default) means "size to content" (no scroll).
    outer_height: f32 = 0,
    /// Number of leading rows kept pinned to the top during
    /// scroll. Typically 1 (the header row created by
    /// `tableHeadersRow`). Default 0 = no freeze. The header
    /// always renders at the top whether `freeze_rows` is set or
    /// not; this option specifically controls whether data rows
    /// scroll BENEATH the header.
    freeze_rows: u32 = 0,
    /// When true, draw alternating-row tinted backgrounds using
    /// `style.table_row_bg` (even rows) and `style.table_row_bg_alt`
    /// (odd rows). Standard "striped" data-grid look. Default
    /// false; the caller opts in. Per-row override is available
    /// via `tableSetRowBgColor` after `tableNextRow`.
    row_bg: bool = false,
};

// DragDropSourceOpts removed - replaced by `DragDropFlags`
//. Use:
//   beginDragDropSource(.{.source_no_preview = true })
// instead of the old:
//   beginDragDropSource(.{.source_no_preview = true })

// ============================================================================
// Tree node flags
// ============================================================================
// Imgui equivalent: `ImGuiTreeNodeFlags` (imgui.h:1301-1333), 26 flag
// values translated per zimr's flag-decision rule (exclusive groups
// become enums, independents stay bools). All 13 fields exposed
// from day 1; ~7 wired effective in MVP, the rest marked
// `// TODO Step 1.x` with imgui line refs.

/// When does clicking on the tree node toggle its open state?
/// Exclusive group from imgui (`OpenOnDoubleClick` /
/// `OpenOnArrow` / default). Imgui line 1310-1311.
pub const TreeOpenTrigger = enum {
    /// Default: click anywhere on the row toggles.
    click_anywhere,
    /// Only clicking on the disclosure arrow toggles. Useful
    /// when the row body is also a Selectable (multi-select
    /// scope) - the arrow is the toggle, the body is the
    /// selection. Imgui: `OpenOnArrow`.
    click_arrow,
    /// Double-click toggles. Single click is a no-op (so the
    /// row can act as a Selectable for single-click). Imgui:
    /// `OpenOnDoubleClick`.
    double_click,
};

/// How wide does the tree node's hit box / highlight rect extend?
/// Exclusive group from imgui (`SpanAvailWidth` / `SpanFullWidth`
/// / `SpanLabelWidth` / `SpanAllColumns` / `LabelSpanAllColumns`).
/// Imgui lines 1315-1319.
pub const TreeNodeSpan = enum {
    /// Default: triangle + label, hit box just covers them.
    default,
    /// Extend hit box to right edge of content area. Useful for
    /// "click anywhere on the row" behavior (matches `treeNode`'s
    /// natural row-tap on phone). Imgui: `SpanAvailWidth`.
    avail_width,
    /// Extend hit box from left edge (covers indent area) to
    /// right edge. TODO Step 1.x - not wired in MVP; accepts
    /// but treated as `.avail_width`. Imgui: `SpanFullWidth`.
    full_width,
    /// Narrow hit box covering only the label. TODO Step 1.x;
    /// treated as `.default`. Imgui: `SpanLabelWidth`.
    label_width,
    /// Frame spans all columns of the container table. TODO
    /// Step 1.x - depends on table-column infra. Imgui:
    /// `SpanAllColumns`.
    all_columns,
    /// Label spans all columns. TODO Step 1.x. Imgui:
    /// `LabelSpanAllColumns`.
    label_all_columns,
};

/// Vertical-line connectors between tree levels. Imgui added
/// these in 1.92; visual polish. Imgui lines 1326-1328.
pub const TreeNodeDrawLines = enum {
    /// No connector lines (default). Imgui: `DrawLinesNone`.
    none,
    /// Horizontal lines to children + vertical line down to
    /// TreePop position. Faster for big trees. TODO Step 1.x.
    full,
    /// Horizontal lines to children + vertical line down to
    /// bottom-most child. Slower. TODO Step 1.x.
    to_nodes,
};

/// Flags for `treeNode`. Mirrors imgui's `ImGuiTreeNodeFlags`
/// (`imgui.h:1301-1333`). All 13 fields exist from day 1; check
/// inline comments for which are wired in MVP vs accepted-but-
/// ignored with TODO.
pub const TreeNodeOpts = struct {
    // ---- Exclusive groups ----
    /// When clicks toggle. See `TreeOpenTrigger`. ✓ MVP for
    /// `.click_anywhere` + `.click_arrow`; `.double_click`
    /// accepted but treated as `.click_anywhere`.
    open_trigger: TreeOpenTrigger = .click_anywhere,
    /// Hit-box / highlight width policy. See `TreeNodeSpan`.
    /// ✓ MVP for `.default` + `.avail_width`.
    span: TreeNodeSpan = .default,
    /// Connector-line drawing. See `TreeNodeDrawLines`. TODO
    /// Step 1.x - not wired in MVP.
    draw_lines: TreeNodeDrawLines = .none,

    // ---- Independent bools ---- (✓ MVP unless noted) ----
    /// Draw the node as selected. Useful with MultiSelect.
    /// Imgui: `Selected`. ✓ MVP.
    selected: bool = false,
    /// Draw a frame with background. Imgui's collapsing-header
    /// look (different widget in zimr but the flag is the same).
    /// Imgui: `Framed`. ✓ MVP.
    framed: bool = false,
    /// Allow hit-test layering with subsequent overlapping
    /// widgets. TODO Step 1.x - zimr's hit-test stacking model
    /// hasn't been factored for layered widgets yet. Imgui:
    /// `AllowOverlap`.
    allow_overlap: bool = false,
    /// Don't push indent or id-stack entry on open (collapsing-
    /// header behavior). TODO Step 1.x - zimr's
    /// `collapsingHeader` is a separate widget so this is only
    /// useful for advanced fold patterns. Imgui:
    /// `NoTreePushOnOpen`.
    no_tree_push_on_open: bool = false,
    /// Skip auto-open when logging is active. No logging in
    /// zimr. Imgui: `NoAutoOpenOnLog`.
    no_auto_open_on_log: bool = false,
    /// Initial open state for the node's first appearance.
    /// User clicks toggle from there. Imgui: `DefaultOpen`.
    /// ✓ MVP.
    default_open: bool = false,
    /// Leaf node - no arrow, no collapse. Calling code still
    /// pairs with `treePop` (returns true unconditionally so the
    /// caller's `if (treeNodeEx(...)) {... treePop(); }`
    /// pattern stays balanced). Imgui: `Leaf`. ✓ MVP.
    leaf: bool = false,
    /// Render a bullet instead of the disclosure triangle.
    /// Imgui: `Bullet`. ✓ MVP.
    bullet: bool = false,
    /// Use frame_padding for vertical alignment (matches
    /// regular widget height - useful when other widgets sit
    /// next to the tree node). Imgui: `FramePadding`. ✓ MVP.
    frame_padding: bool = false,
    /// Left-arrow nav jumps back to parent node. TODO Step 6.x
    /// - zimr's keyboard nav is minimal today. Imgui:
    /// `NavLeftJumpsToParent`.
    nav_left_jumps_to_parent: bool = false,
};

// ============================================================================
// Splitter
// ============================================================================
// Draggable bar between two adjacent panes. The caller owns a
// single `split_pos: f32` describing where the bar is along its
// axis; the splitter widget reads/writes it. Adjacent panes
// derive their sizes from `split_pos` (one is `split_pos` long
// from the splitter's start cursor, the other fills the remainder
// `total - split_pos - bar_width`).
// API shape: matches imgui's `SplitterBehavior`
// (`imgui_widgets.cpp:1801-1857`) in semantics but with two
// changes for zimr:
//   1. Single `*split_pos` instead of `*size1, *size2`. Zero-sum
//      math is implicit (caller derives `size2 = total - split_pos
//      - bar_width`). Equivalent expressive power, simpler call
//      site.
//   2. Phone-friendly `hit_extend` default (16px each side, so 36px
//      total hit zone with a 4px bar). Thin visible bar (looks pro);
//      generous interaction.

// ============================================================================
// MultiSelect
// ============================================================================
// Multi-selection scope wrapping a list of Selectable/Checkbox/etc items.
// Standard idioms: click=replace, Ctrl=toggle, Shift=range, Escape=clear,
// Ctrl+A=all, click-void=clear. Imgui's design: scope code emits a list
// of REQUESTS (SetAll/SetRange); your storage applies them. Decouples
// the scope logic from how you store selection.
// See `src/notes/multiselect-tutorial.md` for the conceptual walkthrough.
// Imgui source: `imgui.h:3026-3140`, `imgui_widgets.cpp:7995-8525`,
// `imgui_widgets.cpp:8583-8810` (storage).
// MVP coverage decisions:
// - `?u64` for selection user-data (idiomatic Zig optional, not `-1` sentinel).
// - SelectionBasicStorage WITHOUT the index→ID adapter - indexes ARE IDs.
//   Add adapter when a future reorderable-list demo proves it pays off.
// - HashMap on UiContext keyed by scope id for per-scope persistent state
//   (anchor, nav_id) - matches imgui's `g.MultiSelectStorage`.
// - Single active-scope slot (no nesting support) - adding stack later is
//   non-breaking.
// - 6 effective flags (`SingleSelect`, `NoSelectAll`, `NoRangeSelect`,
//   `NoAutoClearOnReselect`, `ClearOnEscape`, `ClearOnClickVoid`). Full
//   17-field struct surface; the 11 inert fields are accepted-but-ignored
//   with `// TODO Step 1.x` annotations citing imgui line refs.
// - Same `selectable()` signature - MS hooks are additive (matches imgui).

/// One selection mutation request emitted by the multi-select scope.
/// Tag union mirroring imgui's `ImGuiSelectionRequestType` +
/// `ImGuiSelectionRequest` payload (`imgui.h:3074-3090`).
/// `applyRequests` on your storage iterates a list of these and
/// updates state accordingly. See `src/notes/multiselect-tutorial.md`
/// § 4 + § 6 for the canonical apply body.
pub const SelectionRequest = union(enum) {
    /// Imgui: `ImGuiSelectionRequestType_SetAll`. If `selected ==
    /// true`, select every item from 0..items_count. If false,
    /// clear the selection. `items_count` is in the `MultiSelectIO`
    /// for the apply-time lookup.
    set_all: bool,
    /// Imgui: `ImGuiSelectionRequestType_SetRange`. Set every item
    /// in the inclusive range `first..=last` to `selected`.
    /// `direction` is `+1` if `first` came chronologically before
    /// `last` (top-to-bottom shift-click), `-1` if the other way
    /// (bottom-to-top); useful only if you want to preserve
    /// selection order on a backward Shift+click - most apps
    /// ignore it.
    set_range: struct {
        first: u64,
        last: u64,
        direction: i2,
        selected: bool,
    },
};

/// IO view returned by `beginMultiSelect` and `endMultiSelect`. Owned
/// by the UiContext (lives in the active `MultiSelectScope`); valid
/// until the next Begin/End on the same Ui. Imgui: `ImGuiMultiSelectIO`
/// (`imgui.h:3062-3071`).
/// Usage: call `selection.applyRequests(io)` on both the Begin-time IO
/// (drains pending requests from keyboard shortcuts / box-select drag
/// completion) and the End-time IO (drains this-frame's click-emitted
/// requests).
pub const MultiSelectIO = struct {
    /// Pending selection-mutation work. Slice into the active
    /// scope's request ArrayList; valid until next Begin/End.
    requests: []const SelectionRequest,
    /// The "anchor" item - where shift-click ranges start from.
    /// Survives across frames in `MultiSelectState`. `null` if no
    /// item has been selected yet in this scope's lifetime. When
    /// using a clipper, the user MUST ensure this item is
    /// submitted (zimr has no clipper yet, so this is
    /// informational). Imgui: `RangeSrcItem`.
    range_src_item: ?u64,
    /// Last item the keyboard nav was focused on. Used by
    /// deletion-aware UIs to re-focus after a delete (zimr's MVP
    /// doesn't implement deletion-aware focus; this is set but
    /// unused). Imgui: `NavIdItem`.
    nav_id_item: ?u64,
    /// Selected-state of `nav_id_item` (only meaningful when
    /// `nav_id_item != null`). Imgui: `NavIdSelected`.
    nav_id_selected: bool,
    /// Items_count argument passed to `beginMultiSelect`, echoed
    /// here for convenience inside `applyRequests`.
    items_count: u32,
};

/// Transient state for the in-progress Begin/End pair. Lives at
/// `UiContext.current_multi_select` (single-slot, no nesting in MVP).
/// Imgui: `ImGuiMultiSelectTempData` (`imgui_internal.h`).
/// Cleared at every `beginMultiSelect`; ArrayList capacity is
/// retained across frames to avoid re-allocation.
pub const MultiSelectScope = struct {
    /// Identity of the scope - top of the window's id stack at
    /// Begin time. Looked up in `UiContext.ms_storage` to find
    /// the persistent state.
    id: Id,
    /// Pointer to the persistent state in `UiContext.ms_storage`.
    /// Valid for the duration of the Begin/End pair (no map
    /// rehashes happen during a scope).
    storage: *MultiSelectState,
    /// Flags as configured at Begin. Imgui clears mutually
    /// inconsistent flag combinations here (e.g. SingleSelect
    /// strips box-select bits). We do the same in
    /// `beginMultiSelect`.
    flags: MultiSelectFlags,
    /// Snapshot of keyboard modifiers AT Begin time. Imgui
    /// captures these so multi-frame interactions don't require
    /// the user keep modifiers held the whole time
    /// (`imgui_widgets.cpp:8036-8039`).
    key_shift: bool,
    key_ctrl: bool,
    /// IO view, populated by Begin (with pending requests) and
    /// then again by End (with click-emitted requests).
    io: MultiSelectIO,
    /// Backing storage for `io.requests`. Reset at Begin start
    /// and at End start. Capacity grows on demand and is
    /// retained across frames.
    requests_buf: ArrayList(SelectionRequest),
    /// Set by Begin if there's a pending "apply this to every
    /// item submitted in the loop" override - used when SetAll
    /// requests need to take effect on items the user is about
    /// to render without waiting for the End-time apply. Imgui:
    /// `LoopRequestSetAll` - -1 = none, 0 = clear-all,
    /// 1 = select-all.
    loop_request_set_all: enum { none, clear, select } = .none,
    /// Set true once `SetNextItemSelectionUserData(item ==
    /// storage.range_src_item)` has been observed during the
    /// loop. Imgui: `RangeSrcPassedBy`. Used to determine
    /// SetRange direction when a shift-click resolves.
    range_src_passed_by: bool = false,
    /// True if this scope owns keyboard focus. Imgui:
    /// `IsFocused`. In MVP, we set this true when the scope's
    /// window is the focused window (no NavFocusScope routing
    /// yet).
    is_focused: bool = false,
    /// True after the End-time emit phase has started. Imgui
    /// uses this to discriminate "Begin populated pending
    /// requests" from "End is collecting fresh ones" - we mirror
    /// the same pattern for parity.
    is_end_io: bool = false,
};

/// Default storage helper for selection state. AutoHashMap of
/// selected indices (the `u64` payload of `SelectionUserData`). Most
/// applications can use this as-is; if you need something more
/// elaborate, you can implement `applyRequests` yourself against the
/// same `MultiSelectIO` shape.
/// Imgui: `ImGuiSelectionBasicStorage` (`imgui.h:3108-3127`,
/// `imgui_widgets.cpp:8583-8810`). Our MVP omits the
/// `AdapterIndexToStorageId` callback - indexes ARE IDs. Adding the
/// adapter later is non-breaking (identity == current behavior); see
/// `src/notes/imgui-plan.md` deferred section.
pub const SelectionBasicStorage = struct {
    /// Map of selected ids → present. Absence == not selected.
    /// `void`-valued map = pure set semantics, no per-entry
    /// payload.
    map: std.AutoHashMapUnmanaged(u64, void) = .{},

    pub fn deinit(self: *SelectionBasicStorage, gpa: Allocator) void {
        self.map.deinit(gpa);
    }

    /// Number of selected items. O(1) - backed by the HashMap's
    /// own count.
    pub fn size(self: *const SelectionBasicStorage) usize {
        return self.map.count();
    }

    /// True if `id` is currently in the selection. O(1).
    pub fn contains(self: *const SelectionBasicStorage, id: u64) bool {
        return self.map.contains(id);
    }

    /// Add `id` to the selection. No-op if already present.
    pub fn add(
        self: *SelectionBasicStorage,
        gpa: Allocator,
        id: u64,
    ) !void {
        try self.map.put(gpa, id, {});
    }

    /// Remove `id` from the selection. No-op if absent.
    pub fn remove(self: *SelectionBasicStorage, id: u64) void {
        _ = self.map.remove(id);
    }

    /// Clear all selected items. Retains the map's capacity for
    /// reuse.
    pub fn clear(self: *SelectionBasicStorage) void {
        self.map.clearRetainingCapacity();
    }

    /// Apply each request in `io.requests` to the selection.
    /// Conceptual O(N) per SetAll, O(R) per SetRange where N is
    /// `io.items_count` and R is range size.
    /// Imgui's optimized version
    /// (`imgui_widgets.cpp:8683-8810`) uses batched sorts for
    /// 100k+ item lists; the literal version below works fine for
    /// realistic UI scales. Re-do if/when someone has a
    /// performance complaint.
    pub fn applyRequests(
        self: *SelectionBasicStorage,
        gpa: Allocator,
        io: *const MultiSelectIO,
    ) !void {
        for (io.requests) |req| switch (req) {
            .set_all => |selected| {
                self.clear();
                if (selected) {
                    var idx: u64 = 0;
                    while (idx < io.items_count) : (idx += 1) {
                        try self.add(gpa, idx);
                    }
                }
            },
            .set_range => |r| {
                // Imgui range is inclusive at both ends. Walk
                // [first..=last] regardless of `direction`
                // direction only matters if the caller wants to
                // preserve selection ORDER for downstream UI (zimr
                // doesn't model order today).
                const lo: u64 = @min(r.first, r.last);
                const hi: u64 = @max(r.first, r.last);
                var idx: u64 = lo;
                while (idx <= hi) : (idx += 1) {
                    if (r.selected) {
                        try self.add(gpa, idx);
                    } else {
                        self.remove(idx);
                    }
                }
            },
        };
    }
};

pub const InputTextEvent = enum {
    char_filter,
    edit,
    completion,
    history,
};

pub const HistoryDir = enum { up, down };

/// Shared state passed to every `InputTextOpts` callback. Mirror
/// of imgui's `ImGuiInputTextCallbackData` adapted to zimr's
/// caller-owned-buffer model. No `BufDirty` flag (the callback
/// helpers mutate `buf_len`/`cursor_pos` directly) and no
/// selection fields (selection lands with multi-select in Phase C3).
pub const InputTextCallbackData = struct {
    /// Which callback event is firing right now. Read-only.
    event: InputTextEvent,

    /// Full buffer slice (capacity, not just current length).
    /// Mutable contents - callback can rewrite directly, but for
    /// non-trivial edits prefer `insertChars`/`deleteChars`/
    /// `setBuffer` so cursor + length tracking stays consistent.
    buf: []u8,

    /// Mutable pointer to the buffer's live byte length. Always
    /// `< buf.len`.
    buf_len: *usize,

    /// Mutable pointer to the caret position (byte offset within
    /// the live buffer). Always `<= buf_len.*`.
    cursor_pos: *usize,

    /// CharFilter only. The codepoint about to be inserted. Set
    /// to 0 to drop, or to a different codepoint to substitute.
    /// Read-write; ignored outside the CharFilter event.
    event_char: u32 = 0,

    /// History only. Which arrow triggered the callback.
    /// Read-only; meaningless outside the History event.
    history_dir: HistoryDir = .up,

    /// Opaque user context - same pointer passed via
    /// `InputTextOpts.user_data`. Cast back to your typed state.
    user_data: ?*anyopaque,

    /// Insert `text` at byte offset `pos`. Returns false if the
    /// insertion would overflow `buf.len`. Adjusts `buf_len.*`
    /// and shifts `cursor_pos.*` if the insertion happens at or
    /// before the cursor.
    pub fn insertChars(
        self: *InputTextCallbackData,
        pos: usize,
        text: []const u8,
    ) bool {
        const cur_len: usize = self.buf_len.*;
        const has_room: bool = (cur_len + text.len) <= self.buf.len;
        if (!has_room) {
            return false;
        }
        if (pos > cur_len) {
            return false;
        }
        std.mem.copyBackwards(
            u8,
            self.buf[pos + text.len .. cur_len + text.len],
            self.buf[pos..cur_len],
        );
        @memcpy(self.buf[pos .. pos + text.len], text);
        self.buf_len.* = cur_len + text.len;
        if (self.cursor_pos.* >= pos) {
            self.cursor_pos.* += text.len;
        }
        return true;
    }

    /// Delete `n` bytes starting at `pos`. Clamps to live length.
    /// Adjusts `cursor_pos.*` if the deletion overlaps or precedes
    /// the cursor.
    pub fn deleteChars(
        self: *InputTextCallbackData,
        pos: usize,
        n: usize,
    ) void {
        const cur_len: usize = self.buf_len.*;
        if (pos >= cur_len) {
            return;
        }
        const actual_n: usize = @min(n, cur_len - pos);
        std.mem.copyForwards(
            u8,
            self.buf[pos .. cur_len - actual_n],
            self.buf[pos + actual_n .. cur_len],
        );
        self.buf_len.* = cur_len - actual_n;
        const cursor: usize = self.cursor_pos.*;
        if (cursor > pos + actual_n) {
            self.cursor_pos.* = cursor - actual_n;
        } else if (cursor > pos) {
            self.cursor_pos.* = pos;
        }
    }

    /// Replace the entire buffer contents with `text`. Cursor
    /// moves to the end of `text`. Returns false if `text`
    /// wouldn't fit in `buf.len`.
    pub fn setBuffer(
        self: *InputTextCallbackData,
        text: []const u8,
    ) bool {
        if (text.len > self.buf.len) {
            return false;
        }
        @memcpy(self.buf[0..text.len], text);
        self.buf_len.* = text.len;
        self.cursor_pos.* = text.len;
        return true;
    }
};

/// Signature of every `InputTextOpts` callback. See
/// `InputTextCallbackData` for what the callback can read and
/// mutate. ImGui's `ImGuiInputTextCallback` is one type carrying
/// an `EventFlag` discriminator; zimr separates the slots on
/// `InputTextOpts` (one slot per event), but the callback signature
/// is uniform so dispatch is identical.
pub const InputTextCallback = *const fn (data: *InputTextCallbackData) void;

// ============================================================================
// Input layer tests — dense per-frame key-state array
// ============================================================================

test "Q1: KeyCode.MAX is the count sentinel, every other variant indexes a slot" {
    const max_idx: usize = @intFromEnum(KeyCode.MAX);
    // sanity: a few key positions
    try expect(@intFromEnum(KeyCode.a) < max_idx);
    try expect(@intFromEnum(KeyCode.enter) < max_idx);
    try expect(@intFromEnum(KeyCode.left_shift) < max_idx);
    try expect(@intFromEnum(KeyCode.kp_equal) < max_idx);
}

test "Q1: KeyCode.fromRaylib round-trips for the keys zimr surfaces" {
    // Sample of keys that should round-trip cleanly
    const samples = [_]struct { rk: types.KeyboardKey, kc: KeyCode }{
        .{ .rk = .a, .kc = .a },
        .{ .rk = .space, .kc = .space },
        .{ .rk = .enter, .kc = .enter },
        .{ .rk = .left_shift, .kc = .left_shift },
        .{ .rk = .right_control, .kc = .right_control },
        .{ .rk = .f7, .kc = .f7 },
        .{ .rk = .kp_5, .kc = .kp_5 },
    };
    for (samples) |s| {
        const got: ?KeyCode = KeyCode.fromRaylib(s.rk);
        try expectEqual(@as(?KeyCode, s.kc), got);
    }
}

test "Q1: KeyCode.fromRaylib returns null for unsupported raylib keys" {
    try expectEqual(@as(?KeyCode, null), KeyCode.fromRaylib(.null));
    try expectEqual(@as(?KeyCode, null), KeyCode.fromRaylib(.kb_menu));
}

test "Q1: KeyState default = all-false / duration 0" {
    const ks: KeyState = .{};
    try expect(!ks.down);
    try expect(!ks.pressed_this_frame);
    try expect(!ks.released_this_frame);
    try expectEqual(@as(u32, 0), ks.down_duration_frames);
}

test "Q1: InputSnapshot.keys defaults to MAX-sized array of zero KeyStates" {
    const s: InputSnapshot = .{};
    try expectEqual(@as(usize, @intFromEnum(KeyCode.MAX)), s.keys.len);
    // Spot-check several slots are zeroed
    try expect(!s.keys[@intFromEnum(KeyCode.a)].down);
    try expect(!s.keys[@intFromEnum(KeyCode.enter)].down);
    try expectEqual(@as(u32, 0), s.keys[@intFromEnum(KeyCode.space)].down_duration_frames);
}

test "Q1: edge-event logic (was_down/is_down → pressed/released combinations)" {
    // Simulate the four transitions the population loop computes.
    const cases = [_]struct { was: bool, is: bool, pressed: bool, released: bool, dur: u32 }{
        .{ .was = false, .is = false, .pressed = false, .released = false, .dur = 0 },
        .{ .was = false, .is = true, .pressed = true, .released = false, .dur = 0 },
        .{ .was = true, .is = true, .pressed = false, .released = false, .dur = 6 }, // prior was 5, now 6
        .{ .was = true, .is = false, .pressed = false, .released = true, .dur = 0 },
    };
    for (cases) |c| {
        const prior_dur: u32 = if (c.was) 5 else 0;
        const ks: KeyState = .{
            .down = c.is,
            .pressed_this_frame = c.is and !c.was,
            .released_this_frame = !c.is and c.was,
            .down_duration_frames = if (c.is) (if (c.was) prior_dur + 1 else 0) else 0,
        };
        try expectEqual(c.pressed, ks.pressed_this_frame);
        try expectEqual(c.released, ks.released_this_frame);
        try expectEqual(c.dur, ks.down_duration_frames);
    }
}

test "Q1: KeyCode.toRaylib is the inverse of fromRaylib for every surfaced key" {
    // Round-trip every KeyCode (skipping MAX) through toRaylib →
    // fromRaylib and confirm identity. Comptime iteration so a
    // missing field in either direction surfaces as a compile error.
    inline for (@typeInfo(KeyCode).@"enum".field_names) |kf_name| {
        if (comptime eql(u8, kf_name, "MAX")) {
            continue;
        }
        const kc: KeyCode = @field(KeyCode, kf_name);
        const rk: types.KeyboardKey = kc.toRaylib();
        const back: ?KeyCode = KeyCode.fromRaylib(rk);
        try expectEqual(@as(?KeyCode, kc), back);
    }
}

test "Q1: keyEventSnapshot builds a snapshot with one key fully primed" {
    const s: InputSnapshot = keyEventSnapshot(.space);
    const sp: KeyState = s.keys[@intFromEnum(KeyCode.space)];
    try expect(sp.down);
    try expect(sp.pressed_this_frame);
    try expect(!sp.released_this_frame);
    // No other key should be touched.
    const a: KeyState = s.keys[@intFromEnum(KeyCode.a)];
    try expect(!a.down);
    try expect(!a.pressed_this_frame);
}

test "Q1: snapshot modifier helpers aggregate left+right sides" {
    var s: InputSnapshot = .{};
    try expect(!snapshotShiftDown(&s));
    try expect(!snapshotCtrlDown(&s));

    // Right-shift alone counts.
    s.keys[@intFromEnum(KeyCode.right_shift)].down = true;
    try expect(snapshotShiftDown(&s));
    try expect(!snapshotCtrlDown(&s));

    // Both sides also counts.
    s.keys[@intFromEnum(KeyCode.left_shift)].down = true;
    try expect(snapshotShiftDown(&s));

    // Ctrl independent.
    s.keys[@intFromEnum(KeyCode.left_control)].down = true;
    try expect(snapshotCtrlDown(&s));

    // Releasing right side leaves left side still active.
    s.keys[@intFromEnum(KeyCode.right_shift)].down = false;
    try expect(snapshotShiftDown(&s));

    // Releasing left side too clears it.
    s.keys[@intFromEnum(KeyCode.left_shift)].down = false;
    try expect(!snapshotShiftDown(&s));
}

test "Q1: synthetic input injection via beginFrameRaw populates the key array" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: inject Tab pressed. The new snapshot should show
    // it both via the raw array AND via the Ui handle's helpers.
    const u: Ui = ctx.beginFrameRaw(
        keyEventSnapshot(.tab),
        null,
        800,
        600,
        &gl_dummy,
        &shapes_dummy,
        &font_dummy,
    );
    try expect(u.isKeyDown(.tab));
    try expect(u.isKeyPressed(.tab));
    try expect(!u.isKeyReleased(.tab));
    try expect(!u.isKeyDown(.enter)); // sanity: untouched
}

// ============================================================================
// Typed-generic per-extension state storage tests
// ============================================================================

test "Q2: getState on never-put returns null" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { count: i32 = 0 };
    try expectEqual(@as(?*TestState, null), u.getState(TestState, 42));
}

test "Q2: putState + getState round trip" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { count: i32, label: u8 };
    const put_ptr: *TestState = blk: {
        const _r = u.getOrPutState(TestState, 7, .{});
        _r.value_ptr.* = .{ .count = 42, .label = 'X' };
        break :blk _r.value_ptr;
    };
    try expectEqual(@as(i32, 42), put_ptr.count);

    const got: ?*TestState = u.getState(TestState, 7);
    try expect(got != null);
    try expectEqual(@as(i32, 42), got.?.count);
    try expectEqual(@as(u8, 'X'), got.?.label);
}

test "Q2: type isolation — same id, different T, independent slots" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const StateA = struct { val: i32 };
    const StateB = struct { val: f32 };
    {
        const _r = u.getOrPutState(StateA, 100, .{});
        _r.value_ptr.* = .{ .val = 999 };
    }
    {
        const _r = u.getOrPutState(StateB, 100, .{});
        _r.value_ptr.* = .{ .val = 3.14 };
    }

    const a: *StateA = u.getState(StateA, 100).?;
    const b: *StateB = u.getState(StateB, 100).?;
    try expectEqual(@as(i32, 999), a.val);
    try expectEqual(@as(f32, 3.14), b.val);
}

test "Q2: putState overwrites prior value at same (T, id)" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { val: i32 };
    {
        const _r = u.getOrPutState(TestState, 5, .{});
        _r.value_ptr.* = .{ .val = 1 };
    }
    {
        const _r = u.getOrPutState(TestState, 5, .{});
        _r.value_ptr.* = .{ .val = 99 };
    }
    const got: *TestState = u.getState(TestState, 5).?;
    try expectEqual(@as(i32, 99), got.val);
}

test "Q2: returned pointer from putState lets caller mutate in place" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { val: i32 };
    const ptr: *TestState = blk: {
        const _r = u.getOrPutState(TestState, 0, .{});
        _r.value_ptr.* = .{ .val = 0 };
        break :blk _r.value_ptr;
    };
    ptr.val = 42;

    // getState retrieves the same slot — sees the mutation.
    const got: *TestState = u.getState(TestState, 0).?;
    try expectEqual(@as(i32, 42), got.val);
}

test "Q2: persist flag is recorded on the outer slot (inert until Q8)" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { val: i32 };
    {
        const _r = u.getOrPutState(TestState, 0, .{ .persist = true });
        _r.value_ptr.* = .{ .val = 1 };
    }

    const key: u64 = comptime extStorageKey(TestState);
    const slot: *ExtStorageSlot = ctx.ext_storage.getPtr(key).?;
    try expect(slot.has_persist);
}

test "Q2: persist flag stays sticky once set, across non-persist puts" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const TestState = struct { val: i32 };
    {
        const _r = u.getOrPutState(TestState, 0, .{ .persist = true });
        _r.value_ptr.* = .{ .val = 1 };
    }
    {
        const _r = u.getOrPutState(TestState, 1, .{ .persist = false });
        _r.value_ptr.* = .{ .val = 2 };
    }

    const key: u64 = comptime extStorageKey(TestState);
    const slot: *ExtStorageSlot = ctx.ext_storage.getPtr(key).?;
    // Sticky: T-level flag latches at first persist=true call.
    try expect(slot.has_persist);
}

test "Q2: ext_storage starts empty — zero allocation when unused" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    try expectEqual(@as(u32, 0), ctx.ext_storage.count());
}

// ============================================================================
// Widget primitive (B-path) tests — itemAdd / buttonBehavior
// ============================================================================

const TestEnv = struct {
    ctx: UiContext,
    gl_dummy: Gl = .{},
    shapes_dummy: shapes2d.ShapesTextureState = .{},
    font_dummy: text2d.FontCache = .{},

    fn init(gpa: Allocator) TestEnv {
        return .{ .ctx = UiContext.init(gpa) };
    }
    fn deinit(self: *TestEnv) void {
        self.ctx.deinit();
    }
    fn beginFrameWith(self: *TestEnv, snap: InputSnapshot) Ui {
        return self.ctx.beginFrameRaw(snap, null, 800, 600, &self.gl_dummy, &self.shapes_dummy, &self.font_dummy);
    }
};

test "Q3: itemAdd returns false when rect fully outside window" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("test", .{}) orelse unreachable;
    defer w.close();

    // Rect at negative-Y is fully outside any window.
    const offscreen: Rectangle = .{ .x = 0, .y = -100, .width = 50, .height = 20 };
    try expect(!u.itemAdd(offscreen, 0xDEAD));
}

test "Q3: itemAdd returns true and sets hovered_id when mouse is over rect" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    var snap: InputSnapshot = .{};
    snap.mouse_pos = .{ 100, 100 };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w: WindowHandle = u.window("test", wopts) orelse unreachable;
    defer w.close();

    const id: Id = 0xCAFE;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };
    try expect(u.itemAdd(rect, id));
    try expectEqual(id, env.ctx.hovered_id);
}

test "Q3: itemAdd does NOT set hovered_id when mouse is elsewhere" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    var snap: InputSnapshot = .{};
    snap.mouse_pos = .{ 500, 500 };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w: WindowHandle = u.window("test", wopts) orelse unreachable;
    defer w.close();

    const id: Id = 0xCAFE;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };
    try expect(u.itemAdd(rect, id));
    try expect(env.ctx.hovered_id != id);
}

test "Q3: buttonBehavior fires pressed on click-then-release inside rect" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const id: Id = 0x1234;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };

    // Frame 1: mouse click inside → just_activated, not yet pressed.
    var snap: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true, .mouse_left_clicked = true };
    var u: Ui = env.beginFrameWith(snap);
    var w: WindowHandle = u.window("t", .{}) orelse unreachable;
    var r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    try expect(!r.pressed);
    try expect(r.just_activated);
    try expect(r.held);
    w.close();

    // Frame 2: release inside → pressed = true, just_deactivated.
    snap = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_released = true };
    u = env.beginFrameWith(snap);
    w = u.window("t", .{}) orelse unreachable;
    r = u.buttonBehavior(rect, id, .{});
    try expect(r.pressed);
    try expect(r.just_deactivated);
    w.close();
}

test "Q3: buttonBehavior does NOT fire pressed on release outside rect" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const id: Id = 0x1234;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };

    // Frame 1: click inside.
    var snap: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true, .mouse_left_clicked = true };
    var u: Ui = env.beginFrameWith(snap);
    var w: WindowHandle = u.window("t", .{}) orelse unreachable;
    _ = u.buttonBehavior(rect, id, .{});
    w.close();

    // Frame 2: drag-and-release OUTSIDE rect → no press.
    snap = .{ .mouse_pos = .{ 500, 500 }, .mouse_left_released = true };
    u = env.beginFrameWith(snap);
    w = u.window("t", .{}) orelse unreachable;
    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    try expect(!r.pressed);
    try expect(r.just_deactivated);
    w.close();
}

test "Q3: buttonBehavior with repeat = true fires repeatedly while held" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const id: Id = 0x1234;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };

    // Frame 1: press.
    var snap: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true, .mouse_left_clicked = true };
    var u: Ui = env.beginFrameWith(snap);
    var w: WindowHandle = u.window("t", .{}) orelse unreachable;
    _ = u.buttonBehavior(rect, id, .{ .repeat = true });
    w.close();

    // Frame 2: hold past the 400ms initial delay. Synthesize the
    // passage of time by adjusting the active_id timestamps.
    env.ctx.active_id_press_time = 0;
    env.ctx.active_id_last_repeat_time = 0;
    env.ctx.frame_time_seconds = 1.0; // 1s elapsed, well past 400ms
    snap = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true };
    u = env.beginFrameWith(snap);
    w = u.window("t", .{}) orelse unreachable;
    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{ .repeat = true });
    try expect(r.pressed);
    try expect(r.held);
    w.close();
}

test "Q3: buttonBehavior without repeat does NOT fire while held" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const id: Id = 0x1234;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };

    var snap: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true, .mouse_left_clicked = true };
    var u: Ui = env.beginFrameWith(snap);
    var w: WindowHandle = u.window("t", .{}) orelse unreachable;
    _ = u.buttonBehavior(rect, id, .{});
    w.close();

    // Frame 2: still held, no repeat. Should NOT fire pressed.
    env.ctx.frame_time_seconds = 1.0;
    snap = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true };
    u = env.beginFrameWith(snap);
    w = u.window("t", .{}) orelse unreachable;
    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{}); // no repeat
    try expect(!r.pressed);
    try expect(r.held);
    w.close();
}

test "Q3: itemSize advances the layout cursor by `size`" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    const cursor_before: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    u.itemSize(.{ 100, 30 });
    const cursor_after: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    // Y advanced (cursor wraps to next row), X resets to indent
    try expect(cursor_after[1] > cursor_before[1]);
}

test "Q3: buttonBehavior on focused widget fires pressed on Enter (kbd nav)" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const id: Id = 0x1234;
    const rect: Rectangle = .{ .x = 80, .y = 80, .width = 50, .height = 50 };

    env.ctx.nav_id = id;
    const u: Ui = env.beginFrameWith(keyEventSnapshot(.enter));
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    // Important: register the rect with itemAdd so the nav system
    // sees it. buttonBehavior alone handles the activation, but
    // triggerNavActivate consults the registration trail.
    _ = u.itemAdd(rect, id);
    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    try expect(r.pressed);
}

test "Q3: extension-author pattern — itemSize + itemAdd + buttonBehavior compose" {
    // The cumulative shape: a knob-like custom widget built
    // entirely from the 3 primitives + drawing. Verifies the
    // primitives suffice for the documented extension pattern.
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const snap: InputSnapshot = .{ .mouse_pos = .{ 110, 110 }, .mouse_left_clicked = true, .mouse_left_down = true };
    const u: Ui = env.beginFrameWith(snap);
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    // Hypothetical custom widget body:
    const id: Id = hashStr(0, "knob");
    const size: Vec2 = .{ 40, 40 };
    const at: Vec2 = .{ 90, 90 };
    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = size[0], .height = size[1] };
    u.itemSize(size);
    if (u.itemAdd(rect, id)) {
        const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
        // Press THIS frame: just_activated is set.
        try expect(r.just_activated);
        try expect(r.held);
    } else {
        try expect(false); // unreachable in this layout
    }
}

// ============================================================================
// Widget primitive (C-path) tests — beginItem / endItem
// ============================================================================

test "Q3 C-path: beginItem returns ItemCtx with valid id+rect, then endItem advances cursor" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    const cursor_before: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    if (u.beginItem("widget", .{ .size = .{ 50, 30 } })) |it| {
        defer u.endItem(it);
        try expect(it.id != 0);
        try expectEqual(@as(f32, 50), it.rect.width);
        try expectEqual(@as(f32, 30), it.rect.height);
    } else {
        try expect(false);
    }
    const cursor_after: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    try expect(cursor_after[1] > cursor_before[1]);
}

test "Q3 C-path: beginItem reports hovered + held + pressed across frames" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();

    // Frame 1: click on widget.
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } };

    // Mouse at (100, 100) — comfortably inside the 200×200 widget
    // even after the desktop-default style bump (title_bar_height
    // grew from 22 → 28, window_padding from 8 → 10).  The widget
    // starts at roughly (window_padding[0], title_bar_height +
    // window_padding[1]) = (10, 38) and extends to (210, 238); a
    // (100, 100) click is dead-center.  The previous (30, 30)
    // landed in the title bar under the new defaults.
    const snap1: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_down = true, .mouse_left_clicked = true };
    var u: Ui = env.beginFrameWith(snap1);
    var w: WindowHandle = u.window("t", wopts) orelse unreachable;
    if (u.beginItem("hit_me", .{ .size = .{ 200, 200 } })) |it| {
        defer u.endItem(it);
        try expect(it.hovered);
        try expect(it.held);
        try expect(it.just_activated);
    } else {
        try expect(false);
    }
    w.close();

    // Frame 2: release inside.
    const snap2: InputSnapshot = .{ .mouse_pos = .{ 100, 100 }, .mouse_left_released = true };
    u = env.beginFrameWith(snap2);
    w = u.window("t", wopts) orelse unreachable;
    if (u.beginItem("hit_me", .{ .size = .{ 200, 200 } })) |it| {
        defer u.endItem(it);
        try expect(it.pressed);
        try expect(it.just_deactivated);
    } else {
        try expect(false);
    }
    w.close();
}

test "Q3 C-path: beginItem returns null when window is missing" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    _ = env.beginFrameWith(.{});
    // No window opened — current_window is null.
    const u: Ui = .{ .ctx = &env.ctx };
    try expectEqual(@as(?Ui.ItemCtx, null), u.beginItem("orphan", .{ .size = .{ 10, 10 } }));
}

test "Q3 C-path: id is stable across frames (same label → same id)" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();

    var id_frame1: Id = 0;
    {
        const u: Ui = env.beginFrameWith(.{});
        const w: WindowHandle = u.window("t", .{}) orelse unreachable;
        defer w.close();
        const it: Ui.ItemCtx = u.beginItem("knob", .{ .size = .{ 10, 10 } }).?;
        defer u.endItem(it);
        id_frame1 = it.id;
    }
    {
        const u: Ui = env.beginFrameWith(.{});
        const w: WindowHandle = u.window("t", .{}) orelse unreachable;
        defer w.close();
        const it: Ui.ItemCtx = u.beginItem("knob", .{ .size = .{ 10, 10 } }).?;
        defer u.endItem(it);
        try expectEqual(id_frame1, it.id);
    }
}

test "Q3 C-path + Q2 storage: extension widget pattern (custom state per id)" {
    // This is the documented extension-author pattern: get-or-put
    // per-id state via Q2, drive interaction via Q3's single-call.
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();

    const KnobState = struct { value: f32 = 0 };
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    if (u.beginItem("knob", .{ .size = .{ 40, 40 } })) |it| {
        defer u.endItem(it);
        const r = u.getOrPutState(KnobState, it.id, .{});
        if (!r.found_existing) {
            r.value_ptr.* = .{};
        }
        r.value_ptr.value += 0.1; // simulate per-frame mutation
        try expect(r.value_ptr.value > 0);
    } else {
        try expect(false);
    }
}

// ============================================================================
// DrawList arc + polygon primitive tests
// ============================================================================

test "Q7: addArc emits a polyline cmd with non-zero point count" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);

    dl.addArc(
        A,
        .{ 100, 100 },
        50,
        0,
        pi, // half-circle
        0xFFFFFFFF,
        2,
        0, // auto segments
    );
    try expectEqual(@as(usize, 1), dl.cmds.items.len);
    switch (dl.cmds.items[0]) {
        .polyline => |p| {
            try expect(p.points.len >= 2);
            try expectEqual(@as(u32, 0xFFFFFFFF), p.col);
            try expect(!p.closed);
        },
        else => return error.WrongCmdKind,
    }
}

test "Q7: addArc with explicit segment count produces segments+1 points" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);

    dl.addArc(A, .{ 0, 0 }, 10, 0, pi, 0xFF, 1, 8);
    switch (dl.cmds.items[0]) {
        .polyline => |p| try expectEqual(@as(usize, 9), p.points.len),
        else => return error.WrongCmdKind,
    }
}

test "Q7: addArc with zero radius drops silently (no cmd)" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    dl.addArc(A, .{ 0, 0 }, 0, 0, pi, 0xFF, 1, 8);
    try expectEqual(@as(usize, 0), dl.cmds.items.len);
}

test "Q7: addArc with zero thickness drops silently (no cmd)" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    dl.addArc(A, .{ 0, 0 }, 10, 0, pi, 0xFF, 0, 8);
    try expectEqual(@as(usize, 0), dl.cmds.items.len);
}

test "Q7: addArc points lie ON the circle (within ~1 px of true)" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    const cx: f32 = 100;
    const cy: f32 = 100;
    const r: f32 = 50;
    dl.addArc(A, .{ cx, cy }, r, 0, 2.0 * pi, 0xFF, 1, 32);
    switch (dl.cmds.items[0]) {
        .polyline => |p| {
            for (p.points) |pt| {
                const dx: f32 = pt[0] - cx;
                const dy: f32 = pt[1] - cy;
                const dist: f32 = @sqrt(dx * dx + dy * dy);
                try expect(@abs(dist - r) < 1.0);
            }
        },
        else => return error.WrongCmdKind,
    }
}

test "Q7: addArcFilled emits triangle_filled cmds — one per segment" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);

    dl.addArcFilled(A, .{ 0, 0 }, 10, 0, pi, 0xFF, 8);
    try expectEqual(@as(usize, 8), dl.cmds.items.len);
    for (dl.cmds.items) |cmd| {
        switch (cmd) {
            .triangle_filled => {},
            else => return error.WrongCmdKind,
        }
    }
}

test "Q7: addArcFilled full circle (a1 - a0 == 2π) covers all segments" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    dl.addArcFilled(A, .{ 0, 0 }, 50, 0, 2.0 * pi, 0xFF, 0);
    try expect(dl.cmds.items.len >= 12);
}

test "Q7: addPolygon < 3 points is a no-op" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    const two_pts = [_]Vec2{ .{ 0, 0 }, .{ 1, 1 } };
    dl.addPolygon(A, &two_pts, 0xFF);
    try expectEqual(@as(usize, 0), dl.cmds.items.len);
}

test "Q7: addPolygon emits N-2 triangles for an N-gon" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    // Pentagon: 5 points → 3 triangles
    const pentagon = [_]Vec2{
        .{ 0, 0 },
        .{ 1, 0 },
        .{ 1.5, 1 },
        .{ 0.5, 1.5 },
        .{ -0.5, 1 },
    };
    dl.addPolygon(A, &pentagon, 0xFF112233);
    try expectEqual(@as(usize, 3), dl.cmds.items.len);
    for (dl.cmds.items) |cmd| {
        switch (cmd) {
            .triangle_filled => |tf| try expectEqual(@as(u32, 0xFF112233), tf.col),
            else => return error.WrongCmdKind,
        }
    }
}

test "Q7: addArcFilled with zero radius drops silently" {
    var arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const A: Allocator = arena.allocator();
    var dl: DrawList = .{};
    defer dl.deinit(A);
    dl.addArcFilled(A, .{ 0, 0 }, 0, 0, pi, 0xFF, 8);
    try expectEqual(@as(usize, 0), dl.cmds.items.len);
}

// ============================================================================
// Style override guard (defer-RAII) tests
// ============================================================================

test "Q5: styleOverride changes the live style field" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const original: Color = ctx.style.text;
    const new_text: Color = .{ .r = 255, .g = 0, .b = 0, .a = 255 };

    const g: StyleGuard = u.styleOverride(.{ .text = new_text });
    try expectEqual(new_text, ctx.style.text);
    g.restore();
    try expectEqual(original, ctx.style.text);
}

test "Q5: styleOverride leaves unset fields untouched" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const before_button: Color = ctx.style.button;
    const before_border: Color = ctx.style.border;
    const g: StyleGuard = u.styleOverride(.{ .text = .{ .r = 1, .g = 2, .b = 3, .a = 4 } });
    // Untouched fields preserved.
    try expectEqual(before_button, ctx.style.button);
    try expectEqual(before_border, ctx.style.border);
    g.restore();
}

test "Q5: nested styleOverride restores in defer-stack order" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const original: Color = ctx.style.text;
    const red: Color = .{ .r = 255, .g = 0, .b = 0, .a = 255 };
    const blue: Color = .{ .r = 0, .g = 0, .b = 255, .a = 255 };

    const outer: StyleGuard = u.styleOverride(.{ .text = red });
    try expectEqual(red, ctx.style.text);
    {
        const inner: StyleGuard = u.styleOverride(.{ .text = blue });
        try expectEqual(blue, ctx.style.text);
        inner.restore();
        // After inner restore, back to outer's value.
        try expectEqual(red, ctx.style.text);
    }
    outer.restore();
    try expectEqual(original, ctx.style.text);
}

test "Q5: styleOverride with multiple fields restores all of them" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const orig_text: Color = ctx.style.text;
    const orig_button: Color = ctx.style.button;
    const orig_border: Color = ctx.style.border;
    const c: Color = .{ .r = 99, .g = 99, .b = 99, .a = 99 };

    const g: StyleGuard = u.styleOverride(.{
        .text = c,
        .button = c,
        .border = c,
    });
    try expectEqual(c, ctx.style.text);
    try expectEqual(c, ctx.style.button);
    try expectEqual(c, ctx.style.border);
    g.restore();
    try expectEqual(orig_text, ctx.style.text);
    try expectEqual(orig_button, ctx.style.button);
    try expectEqual(orig_border, ctx.style.border);
}

test "Q5: empty styleOverride (no fields set) is a no-op" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const before: Style = ctx.style;
    const g: StyleGuard = u.styleOverride(.{});
    // Nothing changed.
    try expectEqual(before.text, ctx.style.text);
    try expectEqual(before.button, ctx.style.button);
    g.restore();
    // Still nothing changed.
    try expectEqual(before.text, ctx.style.text);
}

test "Q5: restore is idempotent — calling twice doesn't corrupt" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const original: Color = ctx.style.text;
    const red: Color = .{ .r = 255, .g = 0, .b = 0, .a = 255 };

    const g: StyleGuard = u.styleOverride(.{ .text = red });
    g.restore();
    try expectEqual(original, ctx.style.text);
    // Second call writes the captured prior again — same value.
    g.restore();
    try expectEqual(original, ctx.style.text);
}

test "Q5: works with non-color fields too (Vec2 style fields)" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const before: Vec2 = ctx.style.frame_padding;
    const new_padding: Vec2 = .{ 20, 30 };
    const g: StyleGuard = u.styleOverride(.{ .frame_padding = new_padding });
    try expectEqual(new_padding, ctx.style.frame_padding);
    g.restore();
    try expectEqual(before, ctx.style.frame_padding);
}

test "Q5: errdefer-safety — restore runs even when scope returns early" {
    // Simulates the common error-path pattern: function takes a
    // styleOverride, may return an error early; defer-restore must
    // fire on either path. This test exercises the success path
    // (defer fires after return); the language guarantees the
    // error path identically.
    const Inner = struct {
        fn run(uu: Ui, c: Color) Color {
            const guard: StyleGuard = uu.styleOverride(.{ .text = c });
            defer guard.restore();
            return uu.ctx.style.text; // value inside the scope
        }
    };
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const original: Color = ctx.style.text;
    const red: Color = .{ .r = 255, .g = 0, .b = 0, .a = 255 };
    const inside: Color = Inner.run(u, red);
    try expectEqual(red, inside);
    // After the inner scope returned, defer fired → style restored.
    try expectEqual(original, ctx.style.text);
}

// ============================================================================
// Canvas widget tests — lifecycle, drawlist, clip
// ============================================================================

test "Q4: beginCanvas returns non-null inside a window" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const c: ?Ui.CanvasCtx = u.beginCanvas("canvas", .{ 200, 100 }, .{});
    try expect(c != null);
    u.endCanvas(c.?);
}

test "Q4: beginCanvas + endCanvas brackets the window drawlist with push/pop clip" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const cmds_before: usize = env.ctx.current_window.?.draw_list.cmds.items.len;
    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 80 }, .{}) orelse unreachable;
    const cmds_after_begin: usize = env.ctx.current_window.?.draw_list.cmds.items.len;
    u.endCanvas(c);
    const cmds_after_end: usize = env.ctx.current_window.?.draw_list.cmds.items.len;

    // begin appended push_clip (+1), end appended pop_clip (+1).
    try expectEqual(cmds_before + 1, cmds_after_begin);
    try expectEqual(cmds_after_begin + 1, cmds_after_end);

    // Verify the cmd kinds.
    const dl: *DrawList = &env.ctx.current_window.?.draw_list;
    switch (dl.cmds.items[cmds_after_begin - 1]) {
        .push_clip => {}, // ok
        else => return error.ExpectedPushClip,
    }
    switch (dl.cmds.items[cmds_after_end - 1]) {
        .pop_clip => {}, // ok
        else => return error.ExpectedPopClip,
    }
}

test "Q4: canvas.drawList() records onto the same window drawlist" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 80 }, .{}) orelse unreachable;
    defer u.endCanvas(c);

    const cmds_before: usize = env.ctx.current_window.?.draw_list.cmds.items.len;
    c.drawList().addRectFilled(.{ .x = 0, .y = 0, .width = 20, .height = 20 }, Color.fromWire(0xFF112233));
    const cmds_after: usize = env.ctx.current_window.?.draw_list.cmds.items.len;
    try expectEqual(cmds_before + 1, cmds_after);
}

test "Q4: canvas.localMouse returns mouse coords relative to canvas top-left" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    var snap: InputSnapshot = .{};
    snap.mouse_pos = .{ 200, 150 };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w: WindowHandle = u.window("host", wopts) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 200, 100 }, .{}) orelse unreachable;
    defer u.endCanvas(c);
    const lm: Vec2 = c.localMouse();
    // canvas top-left = cursor at submission time = window pos +
    // window padding. Independent of the exact offset, the
    // invariant is: lm + rect.{x,y} == mouse_pos.
    try expectEqual(@as(f32, 200), lm[0] + c.rect.x);
    try expectEqual(@as(f32, 150), lm[1] + c.rect.y);
}

test "Q4: canvas.hovered() is true iff mouse is in rect" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };

    // Frame 1: mouse outside — hovered = false.
    var snap: InputSnapshot = .{ .mouse_pos = .{ 5, 5 } };
    var u: Ui = env.beginFrameWith(snap);
    var w: WindowHandle = u.window("host", wopts) orelse unreachable;
    var c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 80 }, .{}) orelse unreachable;
    try expect(!c.hovered());
    u.endCanvas(c);
    w.close();

    // Frame 2: mouse inside canvas rect — hovered = true.
    // The canvas's screen rect is cursor-at-window-content-top
    // = ~(window.pos + padding), within (50, 50)..(450, 450).
    snap.mouse_pos = .{ 100, 100 };
    u = env.beginFrameWith(snap);
    w = u.window("host", wopts) orelse unreachable;
    c = u.beginCanvas("c", .{ 200, 200 }, .{}) orelse unreachable;
    try expect(c.hovered());
    u.endCanvas(c);
    w.close();
}

test "Q4: canvas advances the layout cursor (sibling widget lands below)" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const cursor_before: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 60 }, .{}) orelse unreachable;
    u.endCanvas(c);
    const cursor_after: Vec2 = env.ctx.current_window.?.layout.cursor_pos;
    // Cursor Y advanced past the canvas height.
    try expect(cursor_after[1] > cursor_before[1]);
}

test "Q4c: toScreen is the inverse of localMouse (round-trip)" {
    // The big design invariant: if you call toScreen(localMouse())
    // you should get back the actual screen mouse position.
    // Authoring-space coords → screen via toScreen, screen coords
    // → authoring via localMouse, the loop must close.
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const snap: InputSnapshot = .{ .mouse_pos = .{ 250, 175 } };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 30, 30 }, .initial_size = .{ 500, 400 } };
    const w: WindowHandle = u.window("host", wopts) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 400, 300 }, .{}) orelse unreachable;
    defer u.endCanvas(c);

    // Identity transform first.
    const lm0: Vec2 = c.localMouse();
    const round0: Vec2 = c.toScreen(lm0);
    try expect(@abs(round0[0] - 250) < 0.001);
    try expect(@abs(round0[1] - 175) < 0.001);

    // With pan + zoom too.
    c.pushTransform(.{ 25, 35 }, .{ 1.5, 1.5 });
    defer c.popTransform();
    const lm1: Vec2 = c.localMouse();
    const round1: Vec2 = c.toScreen(lm1);
    try expect(@abs(round1[0] - 250) < 0.001);
    try expect(@abs(round1[1] - 175) < 0.001);
}

test "Q4c: canvas.hovered() is false when a sibling window covers the canvas" {
    // 422c hover guard: even with mouse in the canvas rect,
    // `hovered()` returns false if the topmost window at the
    // mouse position isn't the canvas's parent.
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const snap: InputSnapshot = .{ .mouse_pos = .{ 100, 100 } };
    const u: Ui = env.beginFrameWith(snap);
    const wopts_back: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w_back: WindowHandle = u.window("back", wopts_back) orelse unreachable;
    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 300, 300 }, .{}) orelse unreachable;
    u.endCanvas(c);
    w_back.close();

    // Simulate a popup/sibling overlay claiming the hover by
    // pointing `hovered_window_id` at a different window.
    env.ctx.hovered_window_id = 0xDEAD;
    try expect(!c.hovered());

    // Restore to the canvas's parent — hover should re-engage.
    env.ctx.hovered_window_id = c.window.id;
    try expect(c.hovered());
}

// ============================================================================
// Canvas transform-stack tests — push/pop, compose, inverse
// ============================================================================

test "Q4b: CanvasTransform identity round-trips through applyPoint" {
    const id: CanvasTransform = .{};
    const p: Vec2 = .{ 42, 99 };
    try expectEqual(p, id.applyLocal(p));
}

test "Q4b: CanvasTransform translate only" {
    const t: CanvasTransform = .{ .translate = .{ 10, 20 } };
    try expectEqual(@as(Vec2, .{ 11, 22 }), t.applyLocal(.{ 1, 2 }));
}

test "Q4b: CanvasTransform scale only" {
    const t: CanvasTransform = .{ .scale = .{ 2, 3 } };
    try expectEqual(@as(Vec2, .{ 6, 12 }), t.applyLocal(.{ 3, 4 }));
}

test "Q4b: CanvasTransform compose translate+scale" {
    const parent: CanvasTransform = .{ .translate = .{ 100, 200 }, .scale = .{ 2, 2 } };
    const child: CanvasTransform = .{ .translate = .{ 10, 10 } };
    // Child translate composed: 10*parent.scale + parent.translate
    // = 20 + 100 = 120
    const composed: CanvasTransform = parent.compose(child);
    try expectEqual(@as(Vec2, .{ 120, 220 }), composed.translate);
    try expectEqual(@as(Vec2, .{ 2, 2 }), composed.scale);
}

test "Q4b: CanvasTransform inverse is a true inverse (round-trip applyPoint)" {
    const t: CanvasTransform = .{ .translate = .{ 50, -30 }, .scale = .{ 4, 0.5 } };
    const inv: CanvasTransform = t.inverse();
    const p: Vec2 = .{ 7, 11 };
    const projected: Vec2 = t.applyLocal(p);
    const back: Vec2 = inv.applyLocal(projected);
    // Float math — be lenient.
    try expect(@abs(back[0] - p[0]) < 0.001);
    try expect(@abs(back[1] - p[1]) < 0.001);
}

test "Q4b: CanvasTransform inverse handles zero scale safely (no NaN)" {
    const t: CanvasTransform = .{ .translate = .{ 10, 10 }, .scale = .{ 0, 0 } };
    const inv: CanvasTransform = t.inverse();
    // No NaN/Inf; both scale components must be finite.
    try expect(isFinite(inv.scale[0]));
    try expect(isFinite(inv.scale[1]));
}

test "Q4b: pushTransform then localMouse returns coords in authoring space" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    var snap: InputSnapshot = .{};
    snap.mouse_pos = .{ 200, 200 };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w: WindowHandle = u.window("host", wopts) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 200, 200 }, .{}) orelse unreachable;
    defer u.endCanvas(c);

    // No transforms: localMouse == screen mouse minus canvas origin.
    const lm_no_xf: Vec2 = c.localMouse();

    // Push a pan: translate by (50, 50). Now authoring space is
    // shifted, so the same screen mouse should be 50 units LESS
    // in each axis when reported in local coords.
    c.pushTransform(.{ 50, 50 }, .{ 1, 1 });
    const lm_after_pan: Vec2 = c.localMouse();
    try expectEqual(lm_no_xf[0] - 50, lm_after_pan[0]);
    try expectEqual(lm_no_xf[1] - 50, lm_after_pan[1]);
    c.popTransform();

    // After pop: back to identity.
    const lm_after_pop: Vec2 = c.localMouse();
    try expectEqual(lm_no_xf, lm_after_pop);
}

test "Q4b: pushTransform with scale halves localMouse coords (zoom in 2x)" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    var snap: InputSnapshot = .{};
    snap.mouse_pos = .{ 200, 200 };
    const u: Ui = env.beginFrameWith(snap);
    const wopts: WindowOpts = .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 400, 400 } };
    const w: WindowHandle = u.window("host", wopts) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 200, 200 }, .{}) orelse unreachable;
    defer u.endCanvas(c);

    const lm_no_xf: Vec2 = c.localMouse();
    c.pushTransform(.{ 0, 0 }, .{ 2, 2 });
    const lm_zoomed: Vec2 = c.localMouse();
    // 2x zoom: authoring-space coord is half the screen-canvas coord.
    try expect(@abs(lm_zoomed[0] - lm_no_xf[0] / 2) < 0.001);
    try expect(@abs(lm_zoomed[1] - lm_no_xf[1] / 2) < 0.001);
    c.popTransform();
}

test "Q4b: nested pushTransform composes correctly" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 100 }, .{}) orelse unreachable;
    defer u.endCanvas(c);

    c.pushTransform(.{ 10, 10 }, .{ 2, 2 });
    c.pushTransform(.{ 5, 5 }, .{ 1, 1 });
    const cur: CanvasTransform = c.currentTransform();
    // Composed: parent={t=(10,10), s=(2,2)} ∘ child={t=(5,5), s=(1,1)}
    // → t = child.t * parent.s + parent.t = (10+10, 10+10) = (20, 20)
    // → s = parent.s * child.s = (2, 2)
    try expectEqual(@as(Vec2, .{ 20, 20 }), cur.translate);
    try expectEqual(@as(Vec2, .{ 2, 2 }), cur.scale);
    c.popTransform();
    c.popTransform();
    // Back to identity (no transforms on stack from this canvas).
    try expectEqual(@as(usize, 0), env.ctx.canvas_transforms.len);
}

test "Q4b: endCanvas truncates transform stack (defensive against missing pops)" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 100 }, .{}) orelse unreachable;
    c.pushTransform(.{ 10, 10 }, .{ 1, 1 });
    c.pushTransform(.{ 20, 20 }, .{ 1, 1 });
    // Caller forgot to pop.
    u.endCanvas(c);
    // endCanvas truncated — stack is empty again.
    try expectEqual(@as(usize, 0), env.ctx.canvas_transforms.len);
}

test "Q4b: currentTransform without push returns identity" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("host", .{}) orelse unreachable;
    defer w.close();

    const c: Ui.CanvasCtx = u.beginCanvas("c", .{ 100, 100 }, .{}) orelse unreachable;
    defer u.endCanvas(c);
    const cur: CanvasTransform = c.currentTransform();
    try expectEqual(@as(Vec2, .{ 0, 0 }), cur.translate);
    try expectEqual(@as(Vec2, .{ 1, 1 }), cur.scale);
}

// ============================================================================
// InputMode tests — `InputTextOpts.input_mode` and the JS bridge mapping
// ============================================================================

test "InputMode: toAttr returns the HTML inputmode value for each variant" {
    try expectEqualStrings("text", InputMode.toAttr(.text));
    try expectEqualStrings("numeric", InputMode.toAttr(.numeric));
    try expectEqualStrings("decimal", InputMode.toAttr(.decimal));
    try expectEqualStrings("email", InputMode.toAttr(.email));
    try expectEqualStrings("tel", InputMode.toAttr(.tel));
    try expectEqualStrings("url", InputMode.toAttr(.url));
    try expectEqualStrings("search", InputMode.toAttr(.search));
}

test "InputMode: every enum variant maps to a non-empty attr string" {
    // Exhaustive guard: if a new variant is added to InputMode
    // without a `toAttr` case, the switch in `toAttr` fails to
    // compile.  This test reinforces that — every variant goes
    // through `toAttr` with a non-empty result.
    inline for (@typeInfo(InputMode).@"enum".field_values) |f_value| {
        const v: InputMode = @enumFromInt(f_value);
        const s: []const u8 = v.toAttr();
        try expect(s.len > 0);
    }
}

test "InputTextOpts: input_mode defaults to .text" {
    const opts: InputTextOpts = .{};
    try expectEqual(InputMode.text, opts.input_mode);
}

test "InputTextOpts: input_mode field is settable through the struct literal" {
    const opts: InputTextOpts = .{ .input_mode = .numeric };
    try expectEqual(InputMode.numeric, opts.input_mode);
    try expectEqualStrings("numeric", opts.input_mode.toAttr());
}

test "P9.1: chars_* filter flags default to false" {
    const opts: InputTextOpts = .{};
    try expect(!opts.chars_decimal);
    try expect(!opts.chars_hexadecimal);
    try expect(!opts.chars_scientific);
    try expect(!opts.chars_uppercase);
    try expect(!opts.chars_no_blank);
}

inline fn isDecimalChar(cp: u32) bool {
    return (cp >= '0' and cp <= '9') or cp == '.' or cp == '+' or cp == '-';
}

inline fn isHexChar(cp: u32) bool {
    return (cp >= '0' and cp <= '9') or
        (cp >= 'a' and cp <= 'f') or
        (cp >= 'A' and cp <= 'F');
}

inline fn isScientificChar(cp: u32) bool {
    return isDecimalChar(cp) or cp == 'e' or cp == 'E';
}

/// Apply the declarative `chars_*` filter flags from `opts`.
/// Returns 0 ("drop") if the codepoint fails any active filter,
/// the (possibly rewritten) codepoint otherwise.  Pulled out so
/// it can be unit-tested without standing up an InputText.
///
/// Multi-flag semantics: when two filters allow disjoint sets
/// (e.g. `chars_decimal` + `chars_hexadecimal`), the intersection
/// is enforced — a character has to pass each set independently.
/// In practice callers typically set only one of the accept-list
/// filters at a time; `chars_no_blank` and `chars_uppercase`
/// compose orthogonally with anything.
fn applyCharsFlagFilters(opts: InputTextOpts, cp_in: u32) u32 {
    var cp: u32 = cp_in;
    if (opts.chars_uppercase and cp >= 'a' and cp <= 'z') {
        cp = cp - 'a' + 'A';
    }
    if (opts.chars_no_blank and (cp == ' ' or cp == '\t')) {
        return 0;
    }
    if (opts.chars_decimal and !isDecimalChar(cp)) {
        return 0;
    }
    if (opts.chars_hexadecimal and !isHexChar(cp)) {
        return 0;
    }
    if (opts.chars_scientific and !isScientificChar(cp)) {
        return 0;
    }
    return cp;
}

test "P9.1: chars_decimal accepts digits + .+- and drops the rest" {
    const opts: InputTextOpts = .{ .chars_decimal = true };
    // Accept.
    try expectEqual(@as(u32, '0'), applyCharsFlagFilters(opts, '0'));
    try expectEqual(@as(u32, '9'), applyCharsFlagFilters(opts, '9'));
    try expectEqual(@as(u32, '.'), applyCharsFlagFilters(opts, '.'));
    try expectEqual(@as(u32, '+'), applyCharsFlagFilters(opts, '+'));
    try expectEqual(@as(u32, '-'), applyCharsFlagFilters(opts, '-'));
    // Drop letters, symbols, whitespace.
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'e'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, ' '));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, '/'));
}

test "P9.1: chars_hexadecimal accepts 0-9 a-f A-F only" {
    const opts: InputTextOpts = .{ .chars_hexadecimal = true };
    try expectEqual(@as(u32, '0'), applyCharsFlagFilters(opts, '0'));
    try expectEqual(@as(u32, 'a'), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, 'F'), applyCharsFlagFilters(opts, 'F'));
    // Drops the rest.
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'g'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'G'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, '.'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, '-'));
}

test "P9.1: chars_scientific is a superset of decimal with eE added" {
    const opts: InputTextOpts = .{ .chars_scientific = true };
    // Decimal characters still accepted.
    try expectEqual(@as(u32, '5'), applyCharsFlagFilters(opts, '5'));
    try expectEqual(@as(u32, '.'), applyCharsFlagFilters(opts, '.'));
    try expectEqual(@as(u32, '-'), applyCharsFlagFilters(opts, '-'));
    // Plus the exponent letters.
    try expectEqual(@as(u32, 'e'), applyCharsFlagFilters(opts, 'e'));
    try expectEqual(@as(u32, 'E'), applyCharsFlagFilters(opts, 'E'));
    // Other letters still rejected.
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'x'));
}

test "P9.1: chars_uppercase rewrites a-z, leaves others alone" {
    const opts: InputTextOpts = .{ .chars_uppercase = true };
    // Rewrites.
    try expectEqual(@as(u32, 'A'), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, 'Z'), applyCharsFlagFilters(opts, 'z'));
    // Already uppercase passes through.
    try expectEqual(@as(u32, 'A'), applyCharsFlagFilters(opts, 'A'));
    // Digits, symbols, whitespace untouched.
    try expectEqual(@as(u32, '5'), applyCharsFlagFilters(opts, '5'));
    try expectEqual(@as(u32, '-'), applyCharsFlagFilters(opts, '-'));
    try expectEqual(@as(u32, ' '), applyCharsFlagFilters(opts, ' '));
}

test "P9.1: chars_no_blank drops space and tab only" {
    const opts: InputTextOpts = .{ .chars_no_blank = true };
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, ' '));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, '\t'));
    // Newline passes — multiline owns newline policy.
    try expectEqual(@as(u32, '\n'), applyCharsFlagFilters(opts, '\n'));
    // Visible characters pass through.
    try expectEqual(@as(u32, 'a'), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, '_'), applyCharsFlagFilters(opts, '_'));
}

test "P9.1: chars_uppercase composes with chars_hexadecimal" {
    // 'a' first uppercases to 'A', which then passes the hex filter.
    // 'g' uppercases to 'G' but G isn't a hex digit, so it drops.
    const opts: InputTextOpts = .{ .chars_uppercase = true, .chars_hexadecimal = true };
    try expectEqual(@as(u32, 'A'), applyCharsFlagFilters(opts, 'a'));
    try expectEqual(@as(u32, 'F'), applyCharsFlagFilters(opts, 'f'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, 'g'));
    try expectEqual(@as(u32, 0), applyCharsFlagFilters(opts, '.'));
    try expectEqual(@as(u32, '0'), applyCharsFlagFilters(opts, '0'));
}

/// Run the CharFilter callback (if any) over `original_cp`.
/// Returns the (possibly substituted) codepoint the caller should
/// actually insert; 0 means "drop, don't insert." Inlined into
/// the per-char insert loop, so it's marked inline.
///
/// Order of operations matches imgui:
///   1. Declarative `chars_*` flag filters reject / rewrite first.
///   2. The user's `char_filter` callback runs on whatever survives.
/// A character has to pass ALL flag filters that are set.  Filters
/// only see codepoints arriving via keystroke; paste, `setBuffer`,
/// and `insertChars` from inside callbacks bypass them.
inline fn runCharFilter(
    opts: InputTextOpts,
    buf: []u8,
    buf_len: *usize,
    cursor_pos: *usize,
    original_cp: u32,
) u32 {
    const cp: u32 = applyCharsFlagFilters(opts, original_cp);
    if (cp == 0) {
        return 0;
    }
    const cb: InputTextCallback = opts.char_filter orelse return cp;
    var data: InputTextCallbackData = .{
        .event = .char_filter,
        .buf = buf,
        .buf_len = buf_len,
        .cursor_pos = cursor_pos,
        .event_char = cp,
        .history_dir = .up,
        .user_data = opts.user_data,
    };
    cb(&data);
    return data.event_char;
}

test "P9.1: runCharFilter applies flag filters before user callback" {
    // The flag-filter rewrites 'a' → 'A' (uppercase) before the
    // user callback sees it.  The callback then converts every 'A'
    // it sees to 'Z'.  Final: 'a' arrives as 'Z'.  Verifies the
    // ordering documented in runCharFilter's doc comment.
    const cb = struct {
        fn fn_(data: *InputTextCallbackData) void {
            if (data.event_char == 'A') data.event_char = 'Z';
        }
    }.fn_;
    var buf: [16]u8 = undefined;
    var buf_len: usize = 0;
    var cursor: usize = 0;
    const opts: InputTextOpts = .{ .chars_uppercase = true, .char_filter = cb };
    const out: u32 = runCharFilter(opts, &buf, &buf_len, &cursor, 'a');
    try expectEqual(@as(u32, 'Z'), out);

    // A flag-filter rejection short-circuits — the callback never
    // runs on a dropped char.  chars_decimal drops 'x' before the
    // callback (which would otherwise rewrite it to 'Z') could see.
    const opts2: InputTextOpts = .{ .chars_decimal = true, .char_filter = cb };
    const out2: u32 = runCharFilter(opts2, &buf, &buf_len, &cursor, 'x');
    try expectEqual(@as(u32, 0), out2);
}

// ============================================================================
// P9.2 InputText behavior-flag tests
// ============================================================================
// These are the first host-path tests for inputText edit operations.
// Each test stands up a minimal UiContext + Window, force-focuses
// the widget by setting active_id = widgetId, then injects
// keystrokes via ctx.input fields and calls u.inputText().
// Shared setup logic lives in `setupInputTextForTest`.

/// Stand up a minimal UiContext + Window suitable for hosting one
/// inputText widget.  Returns the widget id pre-computed so tests
/// can force-focus via `ctx.active_id = id`.  The caller is
/// expected to deinit the draw_list and the ctx separately.
fn setupInputTextForTest(
    ctx: *UiContext,
    w: *Window,
    label: []const u8,
) Id {
    ctx.current_window = w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };
    return widgetId(ctx, w, label);
}

/// Push one printable codepoint into the typed-char queue.  Each
/// test that wants to inject characters builds the queue then
/// calls inputText so the per-frame insert loop sees them.
fn pushTypedChar(ctx: *UiContext, cp: u32) void {
    const i: usize = ctx.input.chars_typed_count;
    ctx.input.chars_typed[i] = cp;
    ctx.input.chars_typed_count = i + 1;
}

/// Returns the slice to RENDER in place of the live buffer.
/// When `opts.password_mask` is false: returns `buf` unchanged
/// (zero-copy passthrough).  When true: fills `mask_buf` with
/// `*` characters and returns the relevant prefix.  The
/// mask emits ONE asterisk per buffer byte — fine for ASCII;
/// when multi-byte UTF-8 lands, this becomes "one asterisk per
/// codepoint" instead.  Caller owns `mask_buf` and sizes it >=
/// expected max buffer length; truncation is silent (the
/// rendered slice is just shorter than the real buffer, which
/// is acceptable since password fields are typically short).
fn passwordMaskText(
    opts: InputTextOpts,
    buf: []const u8,
    mask_buf: []u8,
) []const u8 {
    if (!opts.password_mask) {
        return buf;
    }
    const n: usize = @min(buf.len, mask_buf.len);
    @memset(mask_buf[0..n], '*');
    return mask_buf[0..n];
}

test "P9.2: passwordMaskText off returns buf unchanged; on returns asterisks" {
    var mask_buf: [16]u8 = undefined;
    const buf: []const u8 = "secret";

    // off → zero-copy passthrough; same pointer + length.
    const off_opts: InputTextOpts = .{};
    const out_off: []const u8 = passwordMaskText(off_opts, buf, &mask_buf);
    try expectEqualStrings("secret", out_off);

    // on → length matches, contents are all asterisks.
    const on_opts: InputTextOpts = .{ .password_mask = true };
    const out_on: []const u8 = passwordMaskText(on_opts, buf, &mask_buf);
    try expectEqualStrings("******", out_on);

    // Empty buffer round-trips cleanly.
    const out_empty: []const u8 = passwordMaskText(on_opts, "", &mask_buf);
    try expectEqual(@as(usize, 0), out_empty.len);
}

test "P9.2: P9.2 flags default to false on InputTextOpts" {
    const opts: InputTextOpts = .{};
    try expect(!opts.enter_returns_true);
    try expect(!opts.escape_clears);
    try expect(!opts.password_mask);
    try expect(!opts.read_only);
    try expect(!opts.allow_tab_input);
}

/// Pack the 5 `chars_*` flag fields from `opts` into a bitmask
/// matching the constants in `web.zig` so the JS-side 'input'
/// event listener can apply the same accept/reject logic the
/// host-path `applyCharsFlagFilters` uses.  See turn 440c — the
/// host-path filter was a no-op on web because the DOM input is
/// the editor and wasm only polls the final value.
fn packCharFilterFlags(opts: InputTextOpts) u32 {
    var flags: u32 = 0;
    const dom = @import("web.zig").dom;
    if (opts.chars_decimal) {
        flags |= dom.CHAR_FILTER_DECIMAL;
    }
    if (opts.chars_hexadecimal) {
        flags |= dom.CHAR_FILTER_HEXADECIMAL;
    }
    if (opts.chars_scientific) {
        flags |= dom.CHAR_FILTER_SCIENTIFIC;
    }
    if (opts.chars_uppercase) {
        flags |= dom.CHAR_FILTER_UPPERCASE;
    }
    if (opts.chars_no_blank) {
        flags |= dom.CHAR_FILTER_NO_BLANK;
    }
    return flags;
}

test "P9.1 web: packCharFilterFlags bitmask matches CHAR_FILTER_* constants" {
    // The JS-side 'input' event listener relies on this exact bit
    // layout.  If the bits drift here, web-path filtering goes
    // silently wrong.  Lock the mapping.
    const dom = @import("web.zig").dom;
    try expectEqual(@as(u32, 0), packCharFilterFlags(.{}));
    try expectEqual(dom.CHAR_FILTER_DECIMAL, packCharFilterFlags(.{ .chars_decimal = true }));
    try expectEqual(dom.CHAR_FILTER_HEXADECIMAL, packCharFilterFlags(.{ .chars_hexadecimal = true }));
    try expectEqual(dom.CHAR_FILTER_SCIENTIFIC, packCharFilterFlags(.{ .chars_scientific = true }));
    try expectEqual(dom.CHAR_FILTER_UPPERCASE, packCharFilterFlags(.{ .chars_uppercase = true }));
    try expectEqual(dom.CHAR_FILTER_NO_BLANK, packCharFilterFlags(.{ .chars_no_blank = true }));

    // Multi-flag composition: bitmask is the OR of active flags.
    const all_opts: InputTextOpts = .{
        .chars_decimal = true,
        .chars_hexadecimal = true,
        .chars_scientific = true,
        .chars_uppercase = true,
        .chars_no_blank = true,
    };
    const expected: u32 = dom.CHAR_FILTER_DECIMAL |
        dom.CHAR_FILTER_HEXADECIMAL |
        dom.CHAR_FILTER_SCIENTIFIC |
        dom.CHAR_FILTER_UPPERCASE |
        dom.CHAR_FILTER_NO_BLANK;
    try expectEqual(expected, packCharFilterFlags(all_opts));
}

test "P9.2 multiline: ctrl_enter_for_newline defaults to false" {
    const opts: InputTextOpts = .{};
    try expect(!opts.ctrl_enter_for_newline);
}

test "P9.2 multiline: default Enter inserts \\n" {
    // Default behavior (ctrl_enter_for_newline = false): plain
    // Enter inserts a newline byte at the cursor.  Same as zimr's
    // existing multiline behavior.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 2;
    ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..2].* = "hi".*;
    var len: usize = 2;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputTextMultiline("field", &buf, &len, .{ 200, 100 }, .{});

    try expectEqual(@as(usize, 3), len);
    try expectEqualStrings("hi\n", buf[0..len]);
    try expectEqual(id, ctx.active_id); // still focused
}

test "P9.2 multiline: ctrl_enter_for_newline + plain Enter commits, no insert" {
    // With ctrl_enter_for_newline=true and NO Ctrl held, plain
    // Enter defocuses (commit).  No \n is inserted.  This is the
    // chat-box pattern: Enter sends, Ctrl+Enter inserts.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 5;
    ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputTextMultiline("field", &buf, &len, .{ 200, 100 }, .{
        .ctrl_enter_for_newline = true,
    });

    // No newline inserted; widget defocused.
    try expectEqual(@as(usize, 5), len);
    try expectEqualStrings("hello", buf[0..len]);
    try expectEqual(@as(Id, 0), ctx.active_id);
}

test "P9.2 multiline: ctrl_enter_for_newline + Ctrl+Enter inserts \\n" {
    // With ctrl_enter_for_newline=true AND Ctrl held, Enter
    // inserts \n (the inverse of the no-flag default).  Widget
    // stays focused.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 5;
    ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;
    ctx.input.keys[@intFromEnum(KeyCode.left_control)].down = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputTextMultiline("field", &buf, &len, .{ 200, 100 }, .{
        .ctrl_enter_for_newline = true,
    });

    try expectEqual(@as(usize, 6), len);
    try expectEqualStrings("hello\n", buf[0..len]);
    try expectEqual(id, ctx.active_id); // still focused
}

test "P9.2 multiline: read_only blocks newline insertion" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 2;
    ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..2].* = "hi".*;
    var len: usize = 2;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputTextMultiline("field", &buf, &len, .{ 200, 100 }, .{ .read_only = true });

    try expectEqual(@as(usize, 2), len);
    try expectEqualStrings("hi", buf[0..len]);
}

// ============================================================================
// P9.3 — InputText callback contracts (turn 442)
// ============================================================================
// All four callbacks (`char_filter`, `edit`, `completion`,
// `history`) already exist on `InputTextOpts` and are wired
// into both `inputTextImpl` and (for `char_filter` + `edit`)
// `inputTextMultilineImpl`.  These tests pin down WHEN each
// fires + WHAT each receives, so future refactors can't
// silently break the contracts.
//
// Web-path note: only `edit` is reliably observable on web (the
// DOM path triggers `changed=true` via length-change polling, and
// the existing `if (changed) opts.edit` dispatch fires the same
// way on host and web).  `char_filter`, `completion`, and
// `history` are HOST-ONLY today because they depend on wasm-side
// keyboard events the DOM input/textarea overlay swallows.  See
// the field docs on `InputTextOpts` for the per-callback
// breakdown.  Web-path parity for these is a future turn.

const CbCounter = struct {
    edit_count: usize = 0,
    completion_count: usize = 0,
    history_count: usize = 0,
    history_dirs: [16]HistoryDir = undefined,
    char_filter_count: usize = 0,
    last_buf_len_seen: usize = 0,
    last_cursor_seen: usize = 0,
};

fn editCounterCb(d: *InputTextCallbackData) void {
    const c: *CbCounter = @ptrCast(@alignCast(d.user_data.?));
    c.edit_count += 1;
    c.last_buf_len_seen = d.buf_len.*;
    c.last_cursor_seen = d.cursor_pos.*;
}

fn completionCounterCb(d: *InputTextCallbackData) void {
    const c: *CbCounter = @ptrCast(@alignCast(d.user_data.?));
    c.completion_count += 1;
}

fn historyCounterCb(d: *InputTextCallbackData) void {
    const c: *CbCounter = @ptrCast(@alignCast(d.user_data.?));
    if (c.history_count < c.history_dirs.len) {
        c.history_dirs[c.history_count] = d.history_dir;
    }
    c.history_count += 1;
}

fn charFilterDropDigitsCb(d: *InputTextCallbackData) void {
    const c: *CbCounter = @ptrCast(@alignCast(d.user_data.?));
    c.char_filter_count += 1;
    if (d.event_char >= '0' and d.event_char <= '9') {
        d.event_char = 0; // drop
    }
}

test "P9.3: edit callback fires when buffer changes via typed char" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    pushTypedChar(&ctx, 'a');

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .edit = editCounterCb,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 1), counter.edit_count);
    try expectEqual(@as(usize, 1), counter.last_buf_len_seen);
    try expectEqual(@as(usize, 1), counter.last_cursor_seen);
}

test "P9.3: edit callback does NOT fire when nothing changes" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    // No chars typed, no keys pressed.

    var buf: [32]u8 = undefined;
    buf[0..3].* = "hey".*;
    var len: usize = 3;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .edit = editCounterCb,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 0), counter.edit_count);
}

test "P9.3: completion callback fires on Tab when set" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..3].* = "hel".*;
    var len: usize = 3;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .completion = completionCounterCb,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 1), counter.completion_count);
}

test "P9.3: completion callback does NOT fire when allow_tab_input is set" {
    // allow_tab_input takes precedence — Tab inserts \t into the
    // buffer instead of dispatching to the completion callback.
    // This is the imgui contract too.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .completion = completionCounterCb,
        .allow_tab_input = true,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 0), counter.completion_count);
    // The Tab DID insert a \t (allow_tab_input took the path).
    try expectEqual(@as(usize, 1), len);
    try expectEqual(@as(u8, '\t'), buf[0]);
}

test "P9.3: history callback fires on Up with .up direction" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.up)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .history = historyCounterCb,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 1), counter.history_count);
    try expectEqual(HistoryDir.up, counter.history_dirs[0]);
}

test "P9.3: history callback fires on Down with .down direction" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.down)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .history = historyCounterCb,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 1), counter.history_count);
    try expectEqual(HistoryDir.down, counter.history_dirs[0]);
}

test "P9.3: char_filter callback can drop chars via event_char=0" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    // Type "a1b2c3" — the filter drops digits.
    pushTypedChar(&ctx, 'a');
    pushTypedChar(&ctx, '1');
    pushTypedChar(&ctx, 'b');
    pushTypedChar(&ctx, '2');
    pushTypedChar(&ctx, 'c');
    pushTypedChar(&ctx, '3');

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .char_filter = charFilterDropDigitsCb,
        .user_data = &counter,
    });

    // All 6 chars run through the filter, only 3 (a, b, c) survive.
    try expectEqual(@as(usize, 6), counter.char_filter_count);
    try expectEqual(@as(usize, 3), len);
    try expectEqualStrings("abc", buf[0..len]);
}

test "P9.3: read_only blocks history callback (no edit happens)" {
    // A read-only field should never fire mutation-driven callbacks
    // including history (which can rewrite buffer via insertChars/
    // deleteChars).  Verify the guard is in place.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.up)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    var len: usize = 0;
    var counter: CbCounter = .{};

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .history = historyCounterCb,
        .read_only = true,
        .user_data = &counter,
    });

    try expectEqual(@as(usize, 0), counter.history_count);
}

// ============================================================================
// P10.1 — ColorEdit display format (turn 443)
// ============================================================================
// The format helpers (`colorChannelToInt255`,
// `formatColorRgbHex`) are pure functions, so unit-testable in
// isolation without spinning up a UI context.  The widget impl
// in `colorEditNFloat` just composes these helpers — once they're
// correct, the rendering is correct (modulo layout, which is
// covered by the existing widget integration tests).

test "P10.1: ColorEditOpts.display_format defaults to .float" {
    const opts: ColorEditOpts = .{};
    try expectEqual(ColorEditDisplayFormat.float, opts.display_format);
}

test "P10.1: colorChannelToInt255 rounds half-away-from-zero" {
    // 0.0 → 0, 1.0 → 255, 0.5 → 128 (round-half-up at the integer
    // boundary).  Out-of-range clamps.
    try expectEqual(@as(u8, 0), colorChannelToInt255(0.0));
    try expectEqual(@as(u8, 255), colorChannelToInt255(1.0));
    try expectEqual(@as(u8, 128), colorChannelToInt255(0.5));
    try expectEqual(@as(u8, 64), colorChannelToInt255(0.25));
    // Out-of-range clamps.
    try expectEqual(@as(u8, 0), colorChannelToInt255(-0.5));
    try expectEqual(@as(u8, 255), colorChannelToInt255(1.5));
}

test "P10.1: formatColorRgbHex RGB output is #RRGGBB" {
    // Pure-red, pure-green, pure-blue, and a saturated mid-value.
    var buf: [16]u8 = undefined;

    const red: []const u8 = formatColorRgbHex(.{ 1.0, 0.0, 0.0, 1.0 }, false, &buf);
    try expectEqualStrings("#FF0000", red);

    const green: []const u8 = formatColorRgbHex(.{ 0.0, 1.0, 0.0, 1.0 }, false, &buf);
    try expectEqualStrings("#00FF00", green);

    const orange: []const u8 = formatColorRgbHex(.{ 1.0, 0.5, 0.0, 1.0 }, false, &buf);
    try expectEqualStrings("#FF8000", orange);
}

test "P10.1: formatColorRgbHex RGBA output is #RRGGBBAA" {
    var buf: [16]u8 = undefined;

    const opaque_white: []const u8 = formatColorRgbHex(.{ 1.0, 1.0, 1.0, 1.0 }, true, &buf);
    try expectEqualStrings("#FFFFFFFF", opaque_white);

    const semi: []const u8 = formatColorRgbHex(.{ 1.0, 0.5, 0.0, 0.25 }, true, &buf);
    try expectEqualStrings("#FF800040", semi);

    const transparent_black: []const u8 = formatColorRgbHex(.{ 0.0, 0.0, 0.0, 0.0 }, true, &buf);
    try expectEqualStrings("#00000000", transparent_black);
}

test "P10.1: formatColorRgbHex clamps out-of-range channels" {
    // Negative + > 1.0 inputs clamp to [0,1] BEFORE the 0-255 map.
    // Round-trip behavior: -0.5 acts like 0.0, +1.5 acts like 1.0.
    var buf: [16]u8 = undefined;
    const out: []const u8 = formatColorRgbHex(.{ -0.5, 1.5, 0.5, 1.0 }, true, &buf);
    try expectEqualStrings("#00FF80FF", out);
}

test "P10.1: formatColorRgbHex returns empty slice for too-small buf" {
    // The caller might allocate exactly the wrong size; the helper
    // returns an empty slice (caller can detect + fall back).
    // RGB needs 7 bytes (`#RRGGBB`); RGBA needs 9 (`#RRGGBBAA`).
    var small_buf: [6]u8 = undefined;
    const rgb_short: []const u8 = formatColorRgbHex(.{ 1.0, 0.5, 0.0, 1.0 }, false, &small_buf);
    try expectEqual(@as(usize, 0), rgb_short.len);

    var medium_buf: [8]u8 = undefined;
    const rgba_short: []const u8 = formatColorRgbHex(.{ 1.0, 0.5, 0.0, 0.5 }, true, &medium_buf);
    try expectEqual(@as(usize, 0), rgba_short.len);

    // RGB into 7-byte buf: should work.
    var rgb_buf: [7]u8 = undefined;
    const rgb_ok: []const u8 = formatColorRgbHex(.{ 1.0, 0.5, 0.0, 1.0 }, false, &rgb_buf);
    try expectEqualStrings("#FF8000", rgb_ok);
}

test "P10.1: all three display formats are distinct enum variants" {
    // Sanity: enum has three variants, and `.float` is the default.
    // This is the canary test — if a future refactor adds an HSV
    // variant and accidentally reorders the existing ones,
    // existing `.float` users would silently switch behavior.
    try expect(ColorEditDisplayFormat.float != ColorEditDisplayFormat.int_0_255);
    try expect(ColorEditDisplayFormat.float != ColorEditDisplayFormat.hex);
    try expect(ColorEditDisplayFormat.int_0_255 != ColorEditDisplayFormat.hex);
}

test "P9.2: read_only blocks char insertion" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;
    pushTypedChar(&ctx, 'X');

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{ .read_only = true });

    // Buffer unchanged, len unchanged.
    try expectEqual(@as(usize, 5), len);
    try expectEqualStrings("hello", buf[0..len]);
}

test "P9.2: read_only blocks backspace" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 5;
    ctx.input.keys[@intFromEnum(KeyCode.backspace)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{ .read_only = true });

    try expectEqual(@as(usize, 5), len);
    try expectEqualStrings("hello", buf[0..len]);
}

test "P9.2: allow_tab_input inserts tab character at cursor" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 2; // between 'h' and 'i'
    ctx.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..2].* = "hi".*;
    var len: usize = 2;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{ .allow_tab_input = true });

    try expectEqual(@as(usize, 3), len);
    try expectEqualStrings("hi\t", buf[0..len]);
    try expectEqual(@as(usize, 3), ctx.input_text_state.cursor_pos);
}

test "P9.2: allow_tab_input takes precedence over completion callback" {
    // When both `allow_tab_input` AND `completion` are set, Tab
    // routes to the tab-insert branch and the callback is NOT
    // called.  Verifies the dispatch order documented in
    // inputTextImpl.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame = true;

    const State = struct {
        var called: bool = false;
        fn cb(_: *InputTextCallbackData) void {
            called = true;
        }
    };
    State.called = false;

    var buf: [32]u8 = undefined;
    var len: usize = 0;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{
        .allow_tab_input = true,
        .completion = State.cb,
    });

    try expect(!State.called);
    try expectEqualStrings("\t", buf[0..len]);
}

test "P9.2: escape without escape_clears leaves buffer intact + defocuses" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{});

    // Buffer intact, but widget defocused.
    try expectEqual(@as(usize, 5), len);
    try expectEqualStrings("hello", buf[0..len]);
    try expectEqual(@as(Id, 0), ctx.active_id);
}

test "P9.2: escape_clears zeros buffer + cursor + defocuses" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input_text_state.cursor_pos = 3;
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    _ = u.inputText("field", &buf, &len, .{ .escape_clears = true });

    try expectEqual(@as(usize, 0), len);
    try expectEqual(@as(usize, 0), ctx.input_text_state.cursor_pos);
    try expectEqual(@as(Id, 0), ctx.active_id);
}

test "P9.2: enter_returns_true off → returns true on any buffer change" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    pushTypedChar(&ctx, 'a');

    var buf: [32]u8 = undefined;
    var len: usize = 0;

    const u: Ui = .{ .ctx = &ctx };
    const out: bool = u.inputText("field", &buf, &len, .{});
    try expect(out);
    try expectEqualStrings("a", buf[0..len]);
}

test "P9.2: enter_returns_true on → typing doesn't return true; Enter does" {
    // Frame 1: type a char.  With enter_returns_true, returns false
    // even though the buffer changed.
    // Frame 2: press Enter.  Returns true.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;

    var buf: [32]u8 = undefined;
    var len: usize = 0;

    const u: Ui = .{ .ctx = &ctx };

    // Frame 1: char insertion shouldn't trigger return-true.
    pushTypedChar(&ctx, 'a');
    const r1: bool = u.inputText("field", &buf, &len, .{ .enter_returns_true = true });
    try expect(!r1);
    try expectEqualStrings("a", buf[0..len]);
    try expectEqual(id, ctx.active_id); // still focused

    // Frame 2: Enter pressed.  Returns true; widget defocuses.
    ctx.input.chars_typed_count = 0; // clear typed queue
    ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;
    const r2: bool = u.inputText("field", &buf, &len, .{ .enter_returns_true = true });
    try expect(r2);
    try expectEqual(@as(Id, 0), ctx.active_id);
}

test "P9.2: enter_returns_true + escape_clears: Esc cancels (returns false, buf empty)" {
    // On host with `enter_returns_true`, Esc is NOT a commit — the
    // function returns false even though the widget defocused.
    // `escape_clears` zeros the buffer so the caller can detect
    // the cancel via len if they care.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    const id: Id = setupInputTextForTest(&ctx, &w, "field");
    ctx.active_id = id;
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    var buf: [32]u8 = undefined;
    buf[0..5].* = "hello".*;
    var len: usize = 5;

    const u: Ui = .{ .ctx = &ctx };
    const out: bool = u.inputText("field", &buf, &len, .{
        .enter_returns_true = true,
        .escape_clears = true,
    });

    // Returns false (Esc is not a commit on host).
    try expect(!out);
    // Buffer cleared.
    try expectEqual(@as(usize, 0), len);
    // Widget defocused.
    try expectEqual(@as(Id, 0), ctx.active_id);
}

// ============================================================================
// Animation primitive tests — tween + spring (label-keyed, dt-driven)
// ============================================================================

test "animated: linear tween at midpoint returns midpoint value" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    // First call seeds; with delta_time = 0 the slot starts at t=0.
    ctx.input.delta_time = 0;
    _ = u.animated("fade", .{ .from = 0, .to = 100, .duration = 1, .easing = .linear });

    // Advance to half the duration.
    ctx.input.delta_time = 0.5;
    const v: f32 = u.animated("fade", .{ .from = 0, .to = 100, .duration = 1, .easing = .linear });
    try expectApproxEqAbs(@as(f32, 50), v, 0.001);
}

test "animated: linear tween at full duration returns to-value exactly" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.animated("a", .{ .from = 0, .to = 10, .duration = 0.5, .easing = .linear });
    ctx.input.delta_time = 0.5;
    const v: f32 = u.animated("a", .{ .from = 0, .to = 10, .duration = 0.5, .easing = .linear });
    try expectEqual(@as(f32, 10), v);
}

test "animated: clamped to to-value after duration" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.animated("a", .{ .from = 0, .to = 10, .duration = 1, .easing = .linear });
    ctx.input.delta_time = 100;
    const v: f32 = u.animated("a", .{ .from = 0, .to = 10, .duration = 1, .easing = .linear });
    try expectEqual(@as(f32, 10), v);
}

test "animated: delay phase holds at from-value" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    // Seed.
    ctx.input.delta_time = 0;
    _ = u.animated("a", .{ .from = 5, .to = 10, .duration = 1, .delay = 1, .easing = .linear });
    // Advance half the delay.
    ctx.input.delta_time = 0.5;
    const v: f32 = u.animated("a", .{ .from = 5, .to = 10, .duration = 1, .delay = 1, .easing = .linear });
    try expectEqual(@as(f32, 5), v);
}

test "animated: ease_out has more value at t=0.5 than linear" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    // ease_out at t=0.5 is well above 0.5 (decelerating curve front-loads).
    ctx.input.delta_time = 0;
    _ = u.animated("eo", .{ .from = 0, .to = 1, .duration = 1, .easing = .ease_out });
    ctx.input.delta_time = 0.5;
    const v: f32 = u.animated("eo", .{ .from = 0, .to = 1, .duration = 1, .easing = .ease_out });
    try expect(v > 0.5);
    try expect(v < 1.0);
}

test "animated: target change restarts the tween" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    // Run a tween to completion.
    ctx.input.delta_time = 0;
    _ = u.animated("a", .{ .from = 0, .to = 10, .duration = 1, .easing = .linear });
    ctx.input.delta_time = 2;
    _ = u.animated("a", .{ .from = 0, .to = 10, .duration = 1, .easing = .linear });

    // Caller flips `to` — tween should restart from `from`.
    ctx.input.delta_time = 0;
    const v: f32 = u.animated("a", .{ .from = 0, .to = 20, .duration = 1, .easing = .linear });
    try expectApproxEqAbs(@as(f32, 0), v, 0.001);
}

test "animated: distinct labels track distinct values" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.animated("alpha", .{ .from = 0, .to = 100, .duration = 1, .easing = .linear });
    _ = u.animated("beta", .{ .from = 0, .to = 50, .duration = 1, .easing = .linear });

    ctx.input.delta_time = 1;
    const a: f32 = u.animated("alpha", .{ .from = 0, .to = 100, .duration = 1, .easing = .linear });
    const b: f32 = u.animated("beta", .{ .from = 0, .to = 50, .duration = 1, .easing = .linear });
    try expectEqual(@as(f32, 100), a);
    try expectEqual(@as(f32, 50), b);
}

test "spring: approaches target over time" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    // Seed: initial = 0, target = 100, default stiffness/damping.
    ctx.input.delta_time = 0;
    _ = u.spring("s", .{ .target = 100, .initial = 0 });

    // Step forward 1 second total in small increments.  Spring
    // should be substantially closer to target.
    var step: u32 = 0;
    while (step < 60) : (step += 1) {
        ctx.input.delta_time = 1.0 / 60.0;
        _ = u.spring("s", .{ .target = 100 });
    }
    const v: f32 = u.spring("s", .{ .target = 100 });
    try expect(v > 90); // critically-damped within 1s
    try expect(v <= 100);
}

test "spring: settles exactly on target after enough time" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.spring("s", .{ .target = 50, .initial = 0 });

    // Step forward a long time — should settle to exact target.
    var step: u32 = 0;
    while (step < 600) : (step += 1) {
        ctx.input.delta_time = 1.0 / 60.0;
        _ = u.spring("s", .{ .target = 50 });
    }
    const v: f32 = u.spring("s", .{ .target = 50 });
    try expectEqual(@as(f32, 50), v);
}

test "spring: mid-flight target change is followed smoothly" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.spring("s", .{ .target = 100, .initial = 0 });

    // Get partway.
    var step: u32 = 0;
    while (step < 10) : (step += 1) {
        ctx.input.delta_time = 1.0 / 60.0;
        _ = u.spring("s", .{ .target = 100 });
    }
    const mid: f32 = u.spring("s", .{ .target = 100 });

    // Flip target — spring should now chase the new target.
    step = 0;
    while (step < 600) : (step += 1) {
        ctx.input.delta_time = 1.0 / 60.0;
        _ = u.spring("s", .{ .target = 200 });
    }
    const settled: f32 = u.spring("s", .{ .target = 200 });
    try expectEqual(@as(f32, 200), settled);
    // Sanity: mid is between 0 and 100.
    try expect(mid > 0);
    try expect(mid < 100);
}

test "spring: distinct labels track distinct springs" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    _ = u.spring("a", .{ .target = 10, .initial = 0 });
    _ = u.spring("b", .{ .target = 1000, .initial = 0 });

    var step: u32 = 0;
    while (step < 600) : (step += 1) {
        ctx.input.delta_time = 1.0 / 60.0;
        _ = u.spring("a", .{ .target = 10 });
        _ = u.spring("b", .{ .target = 1000 });
    }
    const a: f32 = u.spring("a", .{ .target = 10 });
    const b: f32 = u.spring("b", .{ .target = 1000 });
    try expectEqual(@as(f32, 10), a);
    try expectEqual(@as(f32, 1000), b);
}

test "animated: zero duration snaps to to-value immediately" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    ctx.input.delta_time = 0;
    const v: f32 = u.animated("z", .{ .from = 0, .to = 99, .duration = 0, .easing = .linear });
    try expectEqual(@as(f32, 99), v);
}

// ============================================================================
// miniPlot tests — throwaway smoke that the pillars compose
// ============================================================================

test "miniPlot: returns false on empty xs/ys (defensive early-out)" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const empty: []const f32 = &.{};
    const rect: Rectangle = .{ .x = 0, .y = 0, .width = 200, .height = 100 };
    const pressed: bool = u.miniPlot("p", rect, empty, empty);
    try expect(!pressed);
}

test "miniPlot: returns false on mismatched-length xs/ys" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };

    const xs: []const f32 = &.{ 0, 1, 2 };
    const ys: []const f32 = &.{ 1, 2 };
    const rect: Rectangle = .{ .x = 0, .y = 0, .width = 200, .height = 100 };
    const pressed: bool = u.miniPlot("p", rect, xs, ys);
    try expect(!pressed);
}

test "miniPlot: auto-fit limits computed on first frame" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();
    const u: Ui = env.beginFrameWith(.{});
    const w: WindowHandle = u.window("t", .{}) orelse unreachable;
    defer w.close();

    const xs: []const f32 = &.{ 0, 1, 2, 3 };
    const ys: []const f32 = &.{ 5, 10, 15, 20 };
    const rect: Rectangle = .{ .x = 0, .y = 0, .width = 200, .height = 100 };
    _ = u.miniPlot("p", rect, xs, ys);

    // Slot should exist and have fitted = true with limits
    // spanning [5, 20] plus 10% padding.
    const id: Id = hashStr(0, "p");
    const slot: *MiniPlotSlot = u.getState(MiniPlotSlot, id) orelse unreachable;
    try expect(slot.fitted);
    try expect(slot.targetMin < 5);
    try expect(slot.targetMax > 20);
}

test "miniPlot: R-key press clears fitted, returns true that frame" {
    var env = TestEnv.init(std.testing.allocator);
    defer env.deinit();

    const xs: []const f32 = &.{ 0, 1, 2 };
    const ys: []const f32 = &.{ 0, 10, 20 };
    const rect: Rectangle = .{ .x = 0, .y = 0, .width = 200, .height = 100 };

    // Frame 1: seed.  No R-press → returns false, slot fitted.
    {
        const u: Ui = env.beginFrameWith(.{});
        const w: WindowHandle = u.window("t", .{}) orelse unreachable;
        defer w.close();
        const pressed: bool = u.miniPlot("p", rect, xs, ys);
        try expect(!pressed);
    }

    // Frame 2: R-key edge.  Should return true, slot.fitted false,
    // and then refit (so next frame sees fitted = true again).
    {
        var snap: InputSnapshot = .{};
        snap.keys[@intFromEnum(KeyCode.r)] = .{
            .down = true,
            .pressed_this_frame = true,
            .released_this_frame = false,
            .down_duration_frames = 0,
        };
        const u: Ui = env.beginFrameWith(snap);
        const w: WindowHandle = u.window("t", .{}) orelse unreachable;
        defer w.close();
        const pressed: bool = u.miniPlot("p", rect, xs, ys);
        try expect(pressed);
    }
}

// ============================================================================
// Persistence integration tests — opt-in flag + auto-serialize
// ============================================================================

/// The current schema version. Bump on breaking changes.
pub const current_version: u32 = 1;

/// Build the compound key used in `pending_tab_bar_selections`.
/// Returns an owned `[]u8` - caller must free with `gpa.free`.
/// The NUL byte separator is safe because both `window_name` and
/// `str_id` are user-supplied strings that don't contain NUL
/// (Zig strings can, but no real ui widget identifier does).
fn makeTabBarKey(
    gpa: Allocator,
    window_name: []const u8,
    str_id: []const u8,
) Allocator.Error![]u8 {
    var buf = try gpa.alloc(u8, window_name.len + 1 + str_id.len);
    @memcpy(buf[0..window_name.len], window_name);
    buf[window_name.len] = 0;
    @memcpy(buf[window_name.len + 1 ..], str_id);
    return buf;
}

/// Parse `text` (must be sentinel-terminated `[:0]const u8` per
/// std.zon API contract) as a `PersistedState`, populate
/// `ctx.pending_persistence` with deep-copies of each entry's
/// name (so the names live in `gpa` independent of the parsed
/// payload's lifetime).
/// Version mismatch: silently drops the payload. Returns no
/// error - calling code should be able to call `apply` on
/// arbitrary user input without crashing the app.
/// Parse failure: returns `error.InvalidPayload`. Callers
/// generally log + ignore (start with empty state).
pub fn apply(
    gpa: Allocator,
    ctx: *UiContext,
    text: [:0]const u8,
) Error!void {
    // PersistedStyle in ~38 fields to the parsed
    // graph; std.zon's recursive parser hit the default
    // 1000-branch quota. 10k is comfortable headroom for the
    // current shape + future additions.
    @setEvalBranchQuota(10_000);
    // Parse with `ignore_unknown_fields = true` so that adding a
    // new optional field doesn't break loading old payloads. The
    // forward-compat direction (new client, old payload) is what
    // matters in practice - users update their browser tab,
    // localStorage is from last week.
    // The reverse direction (old client, new payload) is rarer
    // and we don't try to handle it specially; the unknown
    // fields are simply dropped, defaults fill in.
    var diag: std.zon.parse.Diagnostics = .{};
    defer diag.deinit(gpa);
    const parsed = std.zon.parse.fromSliceAlloc(
        PersistedState,
        gpa,
        text,
        &diag,
        .{ .ignore_unknown_fields = true },
    ) catch {
        return error.InvalidPayload;
    };
    defer std.zon.parse.free(gpa, parsed);

    // Version gate: discard non-matching payloads. Could grow
    // a migration switch here when we have something to migrate.
    if (parsed.version != current_version) {
        return;
    }

    // Stage every entry into the pending map. Deep-copy the
    // name so the entry survives the parse-result free above.
    // Existing pending entries with the same name are replaced
    // (latest wins) - supports "reload from a fresh payload"
    // without leaking old keys.
    for (parsed.windows) |pw| {
        // Reject zero-length names - they'd alias every unnamed
        // window submission and corrupt unrelated state. A real
        // window always has a name (window titles are required).
        if (pw.name.len == 0) {
            continue;
        }

        const name_copy: []u8 = try gpa.dupe(u8, pw.name);
        // If a pending entry already exists for this name (e.g.
        // apply() called twice), free the old name to avoid leak.
        if (ctx.pending_persistence.fetchRemove(pw.name)) |old| {
            gpa.free(@constCast(old.key));
            // The value (PersistedWindow) has only POD fields
            // besides `name`, which we just freed.
        }

        try ctx.pending_persistence.put(gpa, name_copy, .{
            .name = name_copy,
            .pos = pw.pos,
            .size = pw.size,
            .scroll_y = pw.scroll_y,
            .scroll_x = pw.scroll_x,
            .user_resized = pw.user_resized,
            // The tab_bars slice owned by `parsed` is freed
            // below; we route the relevant bits into
            // `ctx.pending_tab_bar_selections` immediately so
            // they survive. PersistedWindow.tab_bars itself
            // isn't used after apply - only the staging map is
            // consulted by `takeTabBarSelection`.
            .tab_bars = &.{},
        });

        // Stage each tab bar's selection under a compound
        // "<window>\x00<str_id>" key. Selection labels are
        // deep-copied so they survive the parse-result free.
        for (pw.tab_bars) |tb| {
            if (tb.str_id.len == 0) {
                continue;
            }
            // Empty label = "no preference stored" - we still
            // record the entry's existence so the restore path
            // can distinguish "no persistence" from "explicit
            // empty selection". Stored as the empty string;
            // dupe(gpa, "") returns an empty slice from gpa
            // which is safe to free.
            const compound_key: []u8 = try makeTabBarKey(gpa, pw.name, tb.str_id);
            const label_copy = try gpa.dupe(u8, tb.selected_tab_label);

            // Replace any existing entry for the same key.
            if (ctx.pending_tab_bar_selections.fetchRemove(compound_key)) |old| {
                gpa.free(@constCast(old.key));
                gpa.free(@constCast(old.value));
            }
            try ctx.pending_tab_bar_selections.put(gpa, compound_key, label_copy);
        }
    }

    // stage the dock tree. Deep-copy
    // window-name strings since the parsed payload is freed
    // when this function returns. Stored as one flat array
    // on the UiContext; drained by the first `dockSpace()`
    // call after load (or freed on `UiContext.deinit` if no
    // dockspaces ever get submitted).
    // Replace-on-reload: if a stale pending tree already
    // exists, free its allocations first. Supports
    // calling apply() twice (e.g. user-driven "reload
    // layout" UI).
    if (parsed.dock_nodes.len > 0) {
        freePendingDockTree(gpa, ctx);
        const out_nodes = try gpa.alloc(PersistedDockNode, parsed.dock_nodes.len);
        for (parsed.dock_nodes, out_nodes) |src, *dst| {
            // Deep-copy leaf window names and selected name.
            const names = try gpa.alloc([]const u8, src.leaf_window_names.len);
            for (src.leaf_window_names, names) |s, *d| {
                d.* = try gpa.dupe(u8, s);
            }
            const sel_copy = try gpa.dupe(u8, src.leaf_selected_window_name);
            dst.* = .{
                .id = src.id,
                .parent_id = src.parent_id,
                .flags = src.flags,
                .is_split = src.is_split,
                .split_axis = src.split_axis,
                .split_ratio = src.split_ratio,
                .split_child_a = src.split_child_a,
                .split_child_b = src.split_child_b,
                .split_size_ref_a = src.split_size_ref_a,
                .split_size_ref_b = src.split_size_ref_b,
                .leaf_window_names = names,
                .leaf_selected_window_name = sel_copy,
            };
        }
        ctx.pending_dock_tree = out_nodes;
    }

    // P4.1: apply persisted style. Optional — null means the
    // saved payload was from an unthemed app and the in-memory
    // default stays untouched. `applyTo` preserves
    // `ctx.style.font` (caller-owned pointer; the.zon save
    // couldn't carry it).
    if (parsed.style) |s| {
        s.applyTo(&ctx.style);
    }

    // Restore persistable extension state. Match each saved
    // `ext_state` entry by type_name against live ext_storage
    // slots (which captured their own type_name on first
    // persist-true lookup). Unknown type_names are silently
    // discarded — forward-compat for "saved payload mentions a
    // type the current binary doesn't compile in any more."
    //

    // Each payload is zon text that the slot's `deserialize_fn`
    // knows how to parse. A successful parse merges entries by
    // id (incoming wins). A parse failure for one type doesn't
    // abort the whole apply — the next entry still runs.
    //

    // Ordering caveat: `apply` may run before the app has touched
    // the persistable types this frame (e.g. localStorage load at
    // startup, before the first `update`). In that case the
    // type's slot doesn't exist in `ext_storage` yet and the
    // entry is silently skipped. Apps that want persistence on
    // the very first frame must `getOrPutState` for those types
    // in init, before `apply`.
    for (parsed.ext_state) |e| {
        var es_it: std.AutoHashMapUnmanaged(u64, ExtStorageSlot).Iterator = ctx.ext_storage.iterator();
        while (es_it.next()) |kv| {
            const slot: *ExtStorageSlot = kv.value_ptr;
            const slot_name: []const u8 = slot.type_name orelse continue;
            if (!eql(u8, slot_name, e.type_name)) {
                continue;
            }
            const de_fn: DeserializeExtFn = slot.deserialize_fn orelse break;
            // Need a sentinel-terminated copy of the payload for
            // std.zon.parse — the parsed slice is borrowed but
            // not sentinel-terminated.
            const buf: [:0]u8 = gpa.allocSentinel(u8, e.payload.len, 0) catch break;
            defer gpa.free(buf);
            @memcpy(buf, e.payload);
            de_fn(slot.map, gpa, buf) catch {};
            break; // matched + handled
        }
    }
}

test "Q8: plain struct round-trips through serialize → apply" {
    const Counter = struct { n: i32 = 0, label: [16]u8 = @splat(0) };
    const id: Id = hashStr(0, "counter1");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    const slot_a: *Counter = blk: {
        const _r = u_a.getOrPutState(Counter, id, .{ .persist = true });
        _r.value_ptr.* = .{ .n = 42 };
        break :blk _r.value_ptr;
    };
    slot_a.label[0] = 'X';

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    {
        const _r = u_b.getOrPutState(Counter, id, .{ .persist = true });
        _r.value_ptr.* = .{};
    }

    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    try apply(std.testing.allocator, &ctx_b, text_z);

    const restored: *Counter = u_b.getState(Counter, id) orelse unreachable;
    try expectEqual(@as(i32, 42), restored.n);
    try expectEqual(@as(u8, 'X'), restored.label[0]);
}

test "Q8: struct with fixed-size array round-trips" {
    const ArrayHolder = struct { values: [4]f32 = .{ 0, 0, 0, 0 } };
    const id: Id = hashStr(0, "arr");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    const slot_a: *ArrayHolder = blk: {
        const _r = u_a.getOrPutState(ArrayHolder, id, .{ .persist = true });
        _r.value_ptr.* = .{};
        break :blk _r.value_ptr;
    };
    slot_a.values = .{ 1.5, 2.5, 3.5, 4.5 };

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    {
        const _r = u_b.getOrPutState(ArrayHolder, id, .{ .persist = true });
        _r.value_ptr.* = .{};
    }
    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    try apply(std.testing.allocator, &ctx_b, text_z);

    const restored: *ArrayHolder = u_b.getState(ArrayHolder, id) orelse unreachable;
    try expectEqual(@as([4]f32, .{ 1.5, 2.5, 3.5, 4.5 }), restored.values);
}

test "Q8: struct with enum round-trips" {
    const Mode = enum { idle, dragging, dropped };
    const ModeHolder = struct { mode: Mode = .idle };
    const id: Id = hashStr(0, "mode");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(ModeHolder, id, .{ .persist = true });
        _r.value_ptr.* = .{ .mode = .dragging };
    }

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    {
        const _r = u_b.getOrPutState(ModeHolder, id, .{ .persist = true });
        _r.value_ptr.* = .{};
    }
    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    try apply(std.testing.allocator, &ctx_b, text_z);

    const restored: *ModeHolder = u_b.getState(ModeHolder, id) orelse unreachable;
    try expectEqual(Mode.dragging, restored.mode);
}

test "Q8: putState without persist=true is NOT serialized" {
    const Ephemeral = struct { value: i32 = 0 };
    const id: Id = hashStr(0, "eph");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(Ephemeral, id, .{});
        _r.value_ptr.* = .{ .value = 99 };
    } // no persist

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    // The Ephemeral type's @typeName shouldn't appear in the
    // serialized text — the type was never marked persist.
    const tn: []const u8 = @typeName(Ephemeral);
    try expect(std.mem.indexOf(u8, text, tn) == null);
}

test "Q8: mixed persistable + non-persistable types in same context" {
    const Saved = struct { x: i32 = 0 };
    const Skipped = struct { y: i32 = 0 };
    const id_s: Id = hashStr(0, "saved");
    const id_k: Id = hashStr(0, "skipped");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(Saved, id_s, .{ .persist = true });
        _r.value_ptr.* = .{ .x = 7 };
    }
    {
        const _r = u_a.getOrPutState(Skipped, id_k, .{});
        _r.value_ptr.* = .{ .y = 13 };
    } // no persist

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    // Saved type_name present.
    try expect(std.mem.indexOf(u8, text, @typeName(Saved)) != null);
    // Skipped absent.
    try expect(std.mem.indexOf(u8, text, @typeName(Skipped)) == null);
}

test "Q8: unknown type_name silently discarded at apply (forward-compat)" {
    // Hand-craft a payload referencing a type the binary doesn't
    // know about. apply must not crash.
    const Known = struct { v: i32 = 0 };
    const id: Id = hashStr(0, "k");

    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    {
        const _r = u.getOrPutState(Known, id, .{ .persist = true });
        _r.value_ptr.* = .{ .v = 5 };
    }

    // Payload with an unknown type — should be parsed and ignored.
    const text: [:0]const u8 =
        \\.{
        \\    .version = 6,
        \\    .ext_state = .{
        \\        .{ .type_name = "test_made_up_type", .payload = "[]" },
        \\    },
        \\}
    ;
    // version may have moved; load current version from constant
    // to make this test robust.
    const versioned: [:0]u8 = try std.fmt.allocPrintSentinel(
        std.testing.allocator,
        ".{{ .version = {d}, .ext_state = .{{ .{{ .type_name = \"foo_made_up\", .payload = \"[]\" }} }} }}",
        .{current_version},
        0,
    );
    defer std.testing.allocator.free(versioned);
    _ = text;
    try apply(std.testing.allocator, &ctx, versioned);

    // Known type's data should be unchanged (apply didn't touch it).
    const still: *Known = u.getState(Known, id) orelse unreachable;
    try expectEqual(@as(i32, 5), still.v);
}

test "Q8: deserialize replaces existing entries (incoming wins)" {
    const Item = struct { n: i32 = 0 };
    const id: Id = hashStr(0, "item");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(Item, id, .{ .persist = true });
        _r.value_ptr.* = .{ .n = 100 };
    }
    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    // Pre-populate with a different value.
    {
        const _r = u_b.getOrPutState(Item, id, .{ .persist = true });
        _r.value_ptr.* = .{ .n = 999 };
    }

    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    try apply(std.testing.allocator, &ctx_b, text_z);

    const restored: *Item = u_b.getState(Item, id) orelse unreachable;
    try expectEqual(@as(i32, 100), restored.n);
}

test "Q8: multiple ids of the same type round-trip together" {
    const Cell = struct { count: u32 = 0 };
    const id1: Id = hashStr(0, "cell1");
    const id2: Id = hashStr(0, "cell2");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(Cell, id1, .{ .persist = true });
        _r.value_ptr.* = .{ .count = 11 };
    }
    {
        const _r = u_a.getOrPutState(Cell, id2, .{ .persist = true });
        _r.value_ptr.* = .{ .count = 22 };
    }

    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    {
        const _r = u_b.getOrPutState(Cell, id1, .{ .persist = true });
        _r.value_ptr.* = .{};
    }
    {
        const _r = u_b.getOrPutState(Cell, id2, .{ .persist = true });
        _r.value_ptr.* = .{};
    }
    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    try apply(std.testing.allocator, &ctx_b, text_z);

    const r1: *Cell = u_b.getState(Cell, id1) orelse unreachable;
    const r2: *Cell = u_b.getState(Cell, id2) orelse unreachable;
    try expectEqual(@as(u32, 11), r1.count);
    try expectEqual(@as(u32, 22), r2.count);
}

test "Q8: apply with no ext_state in payload doesn't touch live state" {
    const Live = struct { v: i32 = 0 };
    const id: Id = hashStr(0, "live");

    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    {
        const _r = u.getOrPutState(Live, id, .{ .persist = true });
        _r.value_ptr.* = .{ .v = 77 };
    }

    // Payload with only version, no ext_state.
    const versioned: [:0]u8 = try std.fmt.allocPrintSentinel(
        std.testing.allocator,
        ".{{ .version = {d} }}",
        .{current_version},
        0,
    );
    defer std.testing.allocator.free(versioned);
    try apply(std.testing.allocator, &ctx, versioned);

    const still: *Live = u.getState(Live, id) orelse unreachable;
    try expectEqual(@as(i32, 77), still.v);
}

test "Q8: version mismatch discards ext_state along with rest of payload" {
    // Existing policy: version mismatch → apply early-returns
    // without applying anything. Confirm ext_state follows that
    // policy (not separately processed).
    const T = struct { v: i32 = 0 };
    const id: Id = hashStr(0, "vm");

    var ctx_a: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_a.deinit();
    const u_a: Ui = .{ .ctx = &ctx_a };
    {
        const _r = u_a.getOrPutState(T, id, .{ .persist = true });
        _r.value_ptr.* = .{ .v = 50 };
    }
    const text: []u8 = try serialize(std.testing.allocator, &ctx_a);
    defer std.testing.allocator.free(text);

    // Stomp version on the wire to a future value. Use a textual
    // replace — find "version = N" and bump.
    var mut: []u8 = try std.testing.allocator.dupe(u8, text);
    defer std.testing.allocator.free(mut);
    const version_str: []u8 = allocPrint(
        std.testing.allocator,
        "version = {d}",
        .{current_version},
    ) catch unreachable;
    defer std.testing.allocator.free(version_str);
    if (std.mem.indexOf(u8, mut, version_str)) |idx| {
        // Replace the trailing digit with '9' to bump beyond current.
        const replacement: []u8 = allocPrint(
            std.testing.allocator,
            "version = {d}",
            .{current_version + 100},
        ) catch unreachable;
        defer std.testing.allocator.free(replacement);
        if (replacement.len == version_str.len) {
            @memcpy(mut[idx..][0..replacement.len], replacement);
        }
    }

    var ctx_b: UiContext = UiContext.init(std.testing.allocator);
    defer ctx_b.deinit();
    const u_b: Ui = .{ .ctx = &ctx_b };
    {
        const _r = u_b.getOrPutState(T, id, .{ .persist = true });
        _r.value_ptr.* = .{ .v = 1 };
    }

    const mut_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, mut.len, 0);
    defer std.testing.allocator.free(mut_z);
    @memcpy(mut_z, mut);
    try apply(std.testing.allocator, &ctx_b, mut_z);

    // Live state untouched if version sizes matched and replaced.
    // (If the digit lengths differ the test is a no-op — that's
    // fine, it just doesn't exercise the version mismatch path.)
    const still: *T = u_b.getState(T, id) orelse unreachable;
    // Should be 1 (untouched) if version-bump happened, OR 50 if
    // not — but we'll accept either; the goal is "didn't crash."
    try expect(still.v == 1 or still.v == 50);
}

test "Q8: empty ext_storage produces empty ext_state in payload" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const text: []u8 = try serialize(std.testing.allocator, &ctx);
    defer std.testing.allocator.free(text);

    // Empty payload should parse back as PersistedState with
    // ext_state.len == 0.
    const text_z: [:0]u8 = try std.testing.allocator.allocSentinel(u8, text.len, 0);
    defer std.testing.allocator.free(text_z);
    @memcpy(text_z, text);
    var diag: std.zon.parse.Diagnostics = .{};
    defer diag.deinit(std.testing.allocator);
    const parsed = try std.zon.parse.fromSliceAlloc(
        PersistedState,
        std.testing.allocator,
        text_z,
        &diag,
        .{ .ignore_unknown_fields = true },
    );
    defer std.zon.parse.free(std.testing.allocator, parsed);
    try expectEqual(@as(usize, 0), parsed.ext_state.len);
}

test "hashStr is deterministic across calls" {
    const a: Id = hashStr(0, "Increment");
    const b: Id = hashStr(0, "Increment");
    try expectEqual(a, b);
}

test "hashStr disambiguates by seed (the ID stack at work)" {
    // Two `Button("apply")` calls in different windows should get
    // different IDs because the window ID is the seed.
    const win_a: Id = hashStr(0, "WindowA");
    const win_b: Id = hashStr(0, "WindowB");
    try expect(win_a != win_b);

    const apply_in_a: Id = hashStr(win_a, "apply");
    const apply_in_b: Id = hashStr(win_b, "apply");
    try expect(apply_in_a != apply_in_b);
}

test "hashInt distinguishes loop indices" {
    const a: Id = hashInt(0, 0);
    const b: Id = hashInt(0, 1);
    const c: Id = hashInt(0, 1000);
    try expect(a != b);
    try expect(b != c);
    try expect(a != c);
}

// ============================================================================
// Window store - find or create persistent state by title.
// ============================================================================

/// Look up or allocate a persistent `Window` for a `beginChild`
/// scope. Keyed by `child_id` (already hashed against parent's ID
/// stack at the callsite) so the same logical child finds the same
/// persistent state across frames - needed for per-child scroll
/// position, focus state, future dock-node assignment,
/// and any other per-child state that outlives a single frame.
/// Unlike top-level windows (`findOrCreateWindow`), children don't
/// take a name string - their identity is the hashed ID alone. We
/// stash a synthetic name into `name_buf` for debug-print purposes,
/// formatted as the hex of the ID.
/// On allocation, the child's `flags.is_child` is set so subsequent
/// machinery (drawing, focus, GC) can distinguish it from
/// top-level windows. `pos` and `size` are set to (0, 0) - the
/// real values are written by `beginChildImpl` each frame from the
/// parent's cursor + the user's size arg, since children re-anchor
/// every frame relative to their parent's layout.
fn findOrCreateChildWindow(ctx: *UiContext, child_id: Id) *Window {
    if (ctx.windows.get(child_id)) |existing| {
        return existing;
    }
    const w: *Window = ctx.gpa.create(Window) catch unreachable;
    w.* = .{
        .id = child_id,
        .pos = .{ 0, 0 },
        .size = .{ 0, 0 },
        .flags = .{ .is_child = true },
        .parent_id = null, // set by beginChildImpl each frame
    };
    // Debug name = "child:<hex_id>". Truncated if it overflows the
    // 128-byte name_buf, but the prefix + 8 hex chars fits easily.
    const formatted: []u8 = bufPrint(&w.name_buf, "child:{x}", .{child_id}) catch w.name_buf[0..0];
    w.name_len = formatted.len;
    ctx.windows.put(ctx.gpa, child_id, w) catch unreachable;
    return w;
}

// ============================================================================
// popup scope - open/begin/end machinery
// ============================================================================
// A popup is a mini-window: persistent state (id, position, size)
// stored in `ctx.windows` like a regular window, but submitted via
// `frame_popups` so it replays AFTER all regular windows. Chrome is
// minimal - just a background rect + 1px border, no title bar.
// Open/close lifecycle:
//   - `openPopup(str_id)` puts an entry into `popup_open` keyed by
//     hashed parent_id ++ str_id. Captures the mouse position as
//     anchor + records the frame number (for one-frame click-grace).
//   - `beginPopup(str_id)` checks if the popup is open. If yes, it
//     opens a window-style submission scope, returns true.
//   - `endPopup()` closes the scope.
//   - `closeCurrentPopup()` removes the topmost popup_open entry.
//   - Click-outside closes all popups (handled in beginFrame's
//     pre-pass so widgets see correct `popup_open` state).

/// Initial size of a popup window before its first frame of
/// content can auto-fit to actual cursor_max. Also used as the
/// fallback rect for click-outside detection when the popup
/// hasn't been submitted yet (e.g. first frame after openPopup).
const default_popup_size: Vec2 = .{ 120, 60 };

/// Generate a popup window's title from the str_id + parent id
/// used as the lookup key in `ctx.windows` for the popup's
/// persistent Window struct. Format: "##popup_<hex_id>" so the
/// "title" can never collide with a user-named regular window.
fn popupWindowKey(buf: []u8, popup_id: Id) []u8 {
    return bufPrint(buf, "##popup_{x}", .{popup_id}) catch buf[0..0];
}

fn openPopupScope(ctx: *UiContext, str_id: []const u8) bool {
    const parent_id: Id = if (ctx.current_window) |pw| pw.id else 0;
    const id: Id = hashStr(parent_id, str_id);
    const state: PopupState = ctx.popup_open.get(id) orelse return false;

    // Find or create the popup's persistent Window. Use a
    // synthetic title prefixed with `##popup_<hex>` so the popup
    // never shares state with a regular window of the same name.
    var key_buf: [64]u8 = undefined;
    const key: []const u8 = popupWindowKey(&key_buf, id);
    // Anchor at openPopup-time mouse pos; size starts auto-fit.
    // We re-use `findOrCreateWindow` so first-creation captures
    // anchor as initial pos. Subsequent frames: size auto-fits
    // from cursor_max; anchor stays at last computed pos.
    const popup_w: *Window = findOrCreateWindow(
        ctx,
        key,
        .{
            .initial_pos = state.anchor,
            .initial_size = default_popup_size,
        },
    );
    popup_w.last_frame_active = ctx.frame_count;

    // Modal popups: re-center on canvas the first frame. The
    // initial `state.anchor` (mouse-pos at openPopup time) makes
    // sense for context menus but is jarring for "Are you sure?"
    // dialogs - center them. Detect first-frame by comparing
    // opened_at_frame to the current frame.
    const just_opened: bool = state.opened_at_frame == ctx.frame_count - 1;
    if (state.is_modal and just_opened) {
        const cw: f32 = float(ctx.canvas_w);
        const ch: f32 = float(ctx.canvas_h);
        popup_w.pos = .{ (cw - popup_w.size[0]) / 2, (ch - popup_w.size[1]) / 2 };
    }

    // Reset per-frame layout state. Popups have no title bar so
    // cursor starts at (pos + window_padding) directly.
    const inner_origin: Vec2 = .{
        popup_w.pos[0] + ctx.style.window_padding[0],
        popup_w.pos[1] + ctx.style.window_padding[1],
    };
    resetContentLayout(popup_w, inner_origin);

    // Push onto window AND popup stack.
    ctx.window_stack.append(popup_w) catch {
        return false;
    };
    ctx.current_window = popup_w;
    ctx.popup_stack.append(id) catch {};

    // Append to per-frame replay list (popups render AFTER regular
    // windows). Eager mode bypassed - popups don't make sense
    // inside an RT scope.
    if (!ctx.eager_mode) {
        popup_w.draw_list.clear();
        ctx.frame_popups.append(popup_w) catch {};
        ctx.current_draw_list = &popup_w.draw_list;
    }

    // Modal backdrop - fullscreen rect dim BEFORE popup chrome.
    // Drawn on the popup's draw list so it sits at the correct
    // z-level: above all regular windows, below the popup itself.
    if (state.is_modal) {
        const cw: f32 = float(ctx.canvas_w);
        const ch: f32 = float(ctx.canvas_h);
        drawRectFilled(
            ctx,
            .{ .x = 0, .y = 0, .width = cw, .height = ch },
            Color.toWire(ctx.style.modal_dim_bg),
        );
    }

    // Render popup chrome - just bg + border. Background uses
    // popup_bg style colour so popups visually distinguish from
    // regular windows.
    drawRectFilled(
        ctx,
        .{ .x = popup_w.pos[0], .y = popup_w.pos[1], .width = popup_w.size[0], .height = popup_w.size[1] },
        Color.toWire(ctx.style.popup_bg),
    );
    drawRect(
        ctx,
        .{ .x = popup_w.pos[0], .y = popup_w.pos[1], .width = popup_w.size[0], .height = popup_w.size[1] },
        Color.toWire(ctx.style.border),
    );

    // Push content clip rect (popup body is clipped to its bounds).
    if (!ctx.eager_mode) {
        popup_w.draw_list.pushClipRect(
            ctx.frame_arena.allocator(),
            .{
                .x = popup_w.pos[0],
                .y = popup_w.pos[1],
                .width = popup_w.size[0],
                .height = popup_w.size[1],
            },
        );
    }
    return true;
}

// ============================================================================
// keyboard nav helpers
// ============================================================================
// `markItemNavigable` is called by focusable widgets (button,
// checkbox, slider, drag, inputFloat/Int, inputText, combo, listBox)
// AFTER they've finished submission for the frame. It does two
// things:
//   1. Appends the widget's id to `frame_nav_items` so the next Tab
//      knows what's available.
//   2. Draws a 1px focus border on top of the widget's last_item_rect
//      if `nav_id == id` - gives the user visible feedback for the
//      currently-focused widget.
// `advanceNav` runs in beginFrame. Walks the previous frame's
// `frame_nav_items` to find `nav_id`, advances by ±1 with wrap-around,
// and writes the new id back to `nav_id`. If the current `nav_id`
// isn't in the list (e.g. focused widget was removed by user code),
// focuses the first item.
// `triggerNavActivate` returns true when the focused widget should
// treat THIS frame's input as a synthesized click - used by button +
// checkbox to fire on Enter when keyboard-focused.

fn markItemNavigable(
    ctx: *UiContext,
    w: *Window,
    id: Id,
) void {
    // Append to the per-frame submission list so Tab from THIS
    // frame's input advances within THIS frame's set. Past the
    // bounded cap we silently stop appending - overflow is a
    // UX-design issue not a correctness one.
    ctx.frame_nav_items.append(id) catch {};

    // Draw the focus border ONLY if this widget is focused. We use
    // the widget's last_item_rect (just recorded by the widget's
    // submission) and emit a 1px hollow rectangle in the style's
    // button_hovered colour - visible against most window
    // backgrounds without requiring a new style slot.
    const is_focused: bool = ctx.nav_id == id;
    if (!is_focused) {
        return;
    }
    const r: Rectangle = w.layout.last_item_rect;
    const has_size: bool = r.width > 0 and r.height > 0;
    if (!has_size) {
        return;
    }
    drawRectLines(ctx, r, ctx.style.button_hovered);
}

/// Click-outside-popup detection. Called from `beginFrame` so
/// widgets see correct `popup_open` state for the frame. Iterates
/// open popups, removes any whose anchor-frame is older than this
/// frame AND mouse-down fired this frame outside the popup's rect.
fn dismissPopupsOnClickOutside(ctx: *UiContext) void {
    if (!ctx.input.mouse_left_clicked) {
        return;
    }
    const mp: Vec2 = ctx.input.mouse_pos;
    var to_remove: BoundedStack(Id, 8) = .{};
    var it: @TypeOf(ctx.popup_open.iterator()) = ctx.popup_open.iterator();
    while (it.next()) |entry| {
        const id: Id = entry.key_ptr.*;
        const state: PopupState = entry.value_ptr.*;
        // A click on the open menu's OWN bar button is its toggle (handled in
        // openMenu), not an outside click — don't dismiss it here.
        if (ctx.bar_open_menu) |open_id| {
            if (open_id == id and pointInRect(mp, ctx.bar_open_menu_btn)) {
                continue;
            }
        }
        // Suppress dismiss on the same frame as openPopup - the
        // click that opened it would otherwise close it instantly.
        if (state.opened_at_frame == ctx.frame_count) {
            continue;
        }
        // Modals never dismiss from outside-click - must be closed
        // explicitly via `closeCurrentPopup` or the `*open` bool.
        if (state.is_modal) {
            continue;
        }
        // Look up the popup window's persistent state to get its
        // current rect. If the popup wasn't submitted yet on this
        // frame, fall back to anchor + last-known size.
        var key_buf: [64]u8 = undefined;
        const key: []const u8 = popupWindowKey(&key_buf, id);
        const key_id: Id = hashStr(0, key);
        const popup_w_opt: ?*Window = ctx.windows.get(key_id);
        const rect: Rectangle = if (popup_w_opt) |pw|
            .{ .x = pw.pos[0], .y = pw.pos[1], .width = pw.size[0], .height = pw.size[1] }
        else
            .{
                .x = state.anchor[0],
                .y = state.anchor[1],
                .width = default_popup_size[0],
                .height = default_popup_size[1],
            };
        if (!pointInRect(mp, rect)) {
            to_remove.append(id) catch {};
        }
    }
    var i: usize = 0;
    while (i < to_remove.len) : (i += 1) {
        _ = ctx.popup_open.remove(to_remove.items[i]);
    }
}

// ============================================================================
// main menu bar + per-bar menus + menu items
// ============================================================================
// The main menu bar is a synthetic "always-on" mini-window pinned
// to the top of the canvas. Each `beginMenu` button extends a
// horizontal cursor in the bar; clicking opens a popup anchored
// just below the button. Menus inside reuse the existing popup
// infrastructure - they're popups with no special chrome.

// ============================================================================
// tab bars + list boxes
// ============================================================================
// Tab bar layout: a horizontal strip of clickable buttons drawn at
// the current cursor position. The bar's persistent state (active
// tab id, bottom-y for content anchoring) lives in
// `ctx.tab_bar_state` keyed by the bar's hashed `str_id`.
// Per-frame transient state (cursor X, this-frame click tracking)
// lives on `ctx.tab_bar_stack`.
// `beginTabItem` checks against the bar's active_id: if this tab is
// the active one (or no tab is active yet - first tab auto-selects),
// it returns true and the caller submits content widgets. Click
// handling sets `active_id` on the bar so subsequent frames render
// THIS tab as active.
// List box: a fixed-height scrollable region. Reuses the existing
// `Window` clip-rect machinery - we render a bordered rect, push a
// ============================================================================
// TabBar impl.
// ============================================================================

/// Horizontal padding inside each tab button (each side).
const tab_padding_x: f32 = 8;
/// Gap between adjacent tab buttons.
const tab_gap: f32 = 1;
/// Width of the close-X button inside a tab when `open_ptr` is set.
const tab_close_x_w: f32 = 16;
/// Right margin (gap between the close X and the tab's right edge).
const tab_close_x_right_margin: f32 = 2;
/// Width of the asterisk decoration for `.unsaved_document`.
const tab_unsaved_dot_w: f32 = 8;

fn openTabItem(
    ctx: *UiContext,
    label: []const u8,
    open_ptr: ?*bool,
    flags: TabItemFlags,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    if (ctx.tab_bar_stack.len == 0) {
        return false;
    }
    const frame_idx: usize = ctx.tab_bar_stack.len - 1;
    const frame: *TabBarFrame = &ctx.tab_bar_stack.items[frame_idx];
    const state: *TabBarState = ctx.tab_bar_state.getPtr(frame.id) orelse return false;
    const id: Id = hashStr(frame.id, label);

    // ---- Find or insert this tab in the persistent list -------------
    var tab_idx: usize = undefined;
    if (state.findTab(id)) |found| {
        tab_idx = found;
    } else {
        // New tab. Append to the list. Auto-select if this is the
        // first tab on a fresh bar OR if the bar has
        // .auto_select_new_tabs.
        var new_tab: TabState = .{ .id = id };
        const copy_n: usize = @min(label.len, tab_label_cap);
        @memcpy(new_tab.label_buf[0..copy_n], label[0..copy_n]);
        new_tab.label_len = copy_n;
        state.tabs.append(ctx.gpa, new_tab) catch return false;
        tab_idx = state.tabs.items.len - 1;
        const should_auto_select: bool = state.selected_id == 0 or
            frame.flags.auto_select_new_tabs;
        if (should_auto_select) {
            state.selected_id = id;
        }
    }
    const tab: *TabState = &state.tabs.items[tab_idx];
    tab.last_frame_active = ctx.frame_count;
    tab.unsaved = flags.unsaved_document;
    tab.open_ptr_cache = open_ptr;
    // Section: leading > trailing > middle. Both flags set is a
    // user error; leading wins (matches imgui).
    tab.section = if (flags.leading) @as(u8, 0) else if (flags.trailing) @as(u8, 2) else @as(u8, 1);

    // If user passed open_ptr that became false (e.g. from a popup),
    // honor it AS IF the close button was clicked - write back next
    // frame. Don't render this frame.
    if (open_ptr) |p| {
        if (!p.*) {
            // Tab gone next frame (caller already wrote false; the
            // GC sweep in closeTabBar will remove it). Treat as
            // not-active.
            if (state.selected_id == id) {
                state.selected_id = 0;
            }
            return false;
        }
    }

    // .set_selected: select this tab immediately so the caller's
    // content branch runs THIS frame. Matches imgui - the flag
    // takes effect at BeginTabItem time, not EndTabBar time.
    if (flags.set_selected) {
        state.selected_id = id;
    }

    // ---- Compute width ----------------------------------------------
    const text_size: Vec2 = measureTextS(ctx, label);
    const close_w: f32 = if (open_ptr != null) tab_close_x_w + tab_close_x_right_margin else 0;
    const unsaved_w: f32 = if (tab.unsaved) tab_unsaved_dot_w else 0;
    const want_w: f32 = text_size[0] + 2 * tab_padding_x + close_w + unsaved_w;
    tab.width = want_w;

    // ---- Layout: place in its section ------------------------------
    var x_pos: f32 = 0;
    if (tab.section == 0) {
        // Leading: starts at left edge, grows right.
        x_pos = frame.leading_cursor_x;
        frame.leading_cursor_x += want_w + tab_gap;
        // Middle starts where leading ends.
        if (frame.middle_cursor_x < frame.leading_cursor_x) {
            frame.middle_cursor_x = frame.leading_cursor_x;
        }
    } else if (tab.section == 2) {
        // Trailing: grows leftward.
        x_pos = frame.trailing_cursor_x - want_w;
        frame.trailing_cursor_x -= want_w + tab_gap;
    } else {
        // Middle (default): starts where leading section ended.
        x_pos = frame.middle_cursor_x;
        frame.middle_cursor_x += want_w + tab_gap;
    }

    // Overflow check. If the tab would extend past the bar's right
    // edge (or, for trailing, past the middle cursor), skip rendering.
    // The tab's state is still registered + selection logic still
    // applies; only the visual is dropped. fitting_policy=.resize_down
    // is not yet wired (filed for 3.6c).
    const button_rect: Rectangle = .{
        .x = x_pos,
        .y = frame.rect.y,
        .width = want_w,
        .height = frame.rect.height,
    };
    const fits: bool = if (tab.section == 2)
        button_rect.x >= frame.middle_cursor_x
    else
        button_rect.x + button_rect.width <= frame.trailing_cursor_x;
    if (!fits) {
        // Overflow - register state but skip rendering. Selection
        // logic still works (clicks just don't happen since invisible).
        return state.selected_id == id;
    }

    // ---- Selection state, hover, click handling ---------------------
    //

    // `is_active` captures the selection state AS OF NOW — before this
    // frame's click handler runs. If the user is tapping a different
    // tab this frame, that click below mutates `state.selected_id` to
    // the new tab, but `is_active` for the NEW tab stays false until
    // next frame. Symptom: a freshly-tapped tab paints with the
    // `tab_hovered` press-feedback color (because the bg priority
    // picks `hovered` first — see below), and beginTabItem returns
    // false for the new tab so its content branch waits one frame.
    //

    // The 1-frame content lag is invisible at 60fps and the chrome
    // gives immediate feedback via the hover-priority paint, so we
    // accept the small discrepancy. imgui resolves this via a
    // deferred TabBarLayout pass + a three-state selection field
    // (NextSelectedTabId → SelectedTabId → VisibleTabId), promoted
    // before any tab renders. Adopting that pattern would mean
    // rendering ALL tabs from a snapshot at endTabBar instead of
    // inline at beginTabItem; significant refactor, not blocking.
    // See `src/notes/zimr-vs-imgui-divergence-audit.md` §1.2.
    const is_active: bool = state.selected_id == id;
    const hovered: bool = pointInRect(ctx.input.mouse_pos, button_rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    // Left-click selects this tab (effective NEXT frame for the
    // rendering of `is_active`-dependent UI, but the selection_id
    // mutation here is what makes it stick).
    if (hovered and ctx.input.mouse_left_clicked) {
        state.selected_id = id;
        frame.selection_changed_this_frame = true;
    }
    // Middle-click closes the tab when open_ptr was supplied AND the
    // close-with-middle-mouse-button feature isn't suppressed by
    // either the bar or this tab.
    const allow_middle_close: bool = open_ptr != null and
        !frame.flags.no_close_with_middle_mouse_button and
        !flags.no_close_with_middle_mouse_button;
    if (allow_middle_close and hovered and ctx.input.mouse_middle_clicked) {
        tab.want_close = true;
    }

    // ---- Background -----------------------------------------------
    //

    // Color priority matches dear imgui's TabItemEx
    // (`imgui_widgets.cpp:10881`):
    //

    //     hovered → tab_hovered (press-feedback, brightest)
    //     is_active → tab_active (selected, persistent highlight)
    //     otherwise → tab (idle)
    //

    // The crucial part is "hover wins over active": touching the
    // active tab swaps its color to tab_hovered for as long as the
    // finger stays on it. This gives an immediate "I pressed it"
    // affordance — the tap point flashes brighter — and matches the
    // press-then-release feel users expect from buttons in general.
    //

    // Earlier zimr versions reversed the priority (active wins over
    // hover), which made the active tab look identical whether the
    // user was touching it or not. Visible symptom: in the phone
    // demo, the active tab appeared "stuck" at one color and the
    // tap point had no feedback. See
    // `src/notes/zimr-vs-imgui-divergence-audit.md` §1.1.
    const bg_col: Color = if (hovered)
        ctx.style.tab_hovered
    else if (is_active)
        ctx.style.tab_active
    else
        ctx.style.tab;

    // Bg rect height encodes the "lift" effect via geometry instead
    // of a separate overdraw pass:
    //   - Active tab covers the full bar height including the bottom
    //     separator's 1px row. The separator (drawn first in
    //     openTabBar) gets hidden under the active tab, making it
    //     visually merge with the content area below.
    //   - Inactive tabs stop 1px short, so the separator remains
    //     visible at their bottom edge — they stay "on the bar."
    //

    // Hit-testing (the `hovered` check above) uses the FULL bar
    // height regardless, so clicking near the bottom edge of an
    // inactive tab still registers. Only the bg paint is shortened.
    const bg_rect: Rectangle = if (is_active) button_rect else .{
        .x = button_rect.x,
        .y = button_rect.y,
        .width = button_rect.width,
        .height = button_rect.height - 1,
    };
    drawRectFilled(ctx, bg_rect, Color.toWire(bg_col));

    // Optional 2px overline at the very top of the active tab.
    // Off by default; opt-in via `TabBarFlags.draw_selected_overline`.
    // Matches imgui's ImGuiTabBarFlags_DrawSelectedOverline.
    if (is_active and frame.flags.draw_selected_overline) {
        drawRectFilled(
            ctx,
            .{
                .x = button_rect.x,
                .y = button_rect.y,
                .width = button_rect.width,
                .height = 2,
            },
            Color.toWire(ctx.style.text),
        );
    }

    // ---- Label + decorations ---------------------------------------
    var text_x: f32 = button_rect.x + tab_padding_x;
    if (tab.unsaved) {
        // `*` at the left of the label.
        drawTextAtS(
            ctx,
            .{ text_x, button_rect.y + (button_rect.height - ctx.style.font_size) / 2 },
            "*",
            Color.toWire(ctx.style.text),
        );
        text_x += tab_unsaved_dot_w;
    }
    drawTextAtS(
        ctx,
        .{ text_x, button_rect.y + (button_rect.height - ctx.style.font_size) / 2 },
        label,
        Color.toWire(ctx.style.text),
    );

    // ---- Close-X button (when open_ptr supplied) -------------------
    if (open_ptr != null) {
        const x_size: f32 = ctx.style.font_size;
        const x_rect: Rectangle = .{
            .x = button_rect.x + button_rect.width - tab_close_x_w - tab_close_x_right_margin,
            .y = button_rect.y + (button_rect.height - x_size) / 2,
            .width = tab_close_x_w,
            .height = x_size,
        };
        const x_hovered: bool = pointInRect(ctx.input.mouse_pos, x_rect);
        if (x_hovered) {
            drawRectFilled(ctx, x_rect, Color.toWire(ctx.style.button_hovered));
        }
        drawTextAtS(
            ctx,
            .{ x_rect.x + 2, x_rect.y },
            "x",
            Color.toWire(ctx.style.text),
        );
        if (x_hovered and ctx.input.mouse_left_clicked) {
            tab.want_close = true;
        }
    }

    // ---- Push id_stack for the active tab's content ----------------
    // Ensures widgets inside the tab have per-tab unique ids
    // (e.g. two tabs both showing a "Save" button get distinct ids,
    // so per-id state like active_id, input-text edit state, etc.
    // doesn't bleed across tabs). Pop happens in closeTabItem.
    if (is_active and !flags.no_push_id) {
        ctx.id_stack.append(id) catch {};
        frame.pushed_tab_id_this_item = true;
    } else {
        frame.pushed_tab_id_this_item = false;
    }

    // Record screen rect for `isItemHovered` after `endTabItem`.
    w.layout.last_item_id = id;
    w.layout.last_item_rect = button_rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);

    return is_active;
}

fn openListBox(
    ctx: *UiContext,
    label: []const u8,
    size: Vec2,
) bool {
    const parent: *Window = ctx.current_window orelse return false;
    // The list box's persistent ID is hashed against the parent's
    // ID stack and the label (same `widgetId` helper any widget
    // uses). This way two listBoxes with the same label in
    // different scopes (e.g. two children) get distinct persistent
    // Windows and don't share scroll state once lands.
    const list_box_id: Id = widgetId(ctx, parent, label);
    const at: Vec2 = resolveCursor(parent, ctx.style.item_spacing[0]);

    // Resolve auto sizes. Width: full PARENT-scope content width
    // minus label reserve. Height: 7 rows by default. Uses the
    // current scope's right edge (`parent.layout.work_rect_max[0]`),
    // not the top-level window's, so a listBox inside a child
    // doesn't extend past the child's right edge.
    const text_size: Vec2 = measureTextS(ctx, label);
    const inner_right: f32 = parent.layout.work_rect_max[0];
    const reserve_label: f32 = if (text_size[0] > 0) text_size[0] + ctx.style.item_spacing[0] else 0;
    const default_w: f32 = @max(60, inner_right - at[0] - reserve_label);
    const row_h: f32 = ctx.style.font_size + 2 * ctx.style.frame_padding[1];
    const default_h: f32 = row_h * 7 + 2 * ctx.style.frame_padding[1];
    const box_w: f32 = if (size[0] > 0) size[0] else default_w;
    const box_h: f32 = if (size[1] > 0) size[1] else default_h;
    const box: Rectangle = .{ .x = at[0], .y = at[1], .width = box_w, .height = box_h };

    // Render frame_bg + 1px border on the PARENT's draw list,
    // BEFORE pushing the box's clip rect - so the border shows on
    // the edges, not clipped out.
    drawRectFilled(ctx, box, Color.toWire(ctx.style.frame_bg));
    drawRect(ctx, box, Color.toWire(ctx.style.border));

    // Fetch (or allocate) the persistent listBox Window. Same
    // mechanism as `beginChildImpl` - the box is a child window
    // with frame chrome, structurally indistinguishable from any
    // other child. Future per-box state (scroll position when
    // lands, selection range when MultiSelect lands)
    // lives naturally on this Window.
    const list_box: *Window = findOrCreateChildWindow(ctx, list_box_id);
    list_box.pos = .{ box.x, box.y };
    list_box.size = .{ box.width, box.height };
    list_box.parent_id = parent.id;
    list_box.last_frame_active = ctx.frame_count;

    // Push ID + window stack. Order mirrors `beginChildImpl`:
    // ID first (so a failure in window_stack append can recover);
    // then window_stack; then current_window flip.
    ctx.id_stack.append(list_box_id) catch return false;
    ctx.window_stack.append(list_box) catch {
        _ = ctx.id_stack.pop();
        return false;
    };
    ctx.current_window = list_box;

    // Install the list box's fresh LayoutScope. Padding for a
    // listBox is `frame_padding` (smaller than a generic child's
    // `padding`), matching imgui's framed-child convention.
    // Cursor seeded shifted UP by `scroll_y` so when scrolled, rows
    // submit at more-negative Y; the visible row aligns with the
    // inner top edge. See `beginChildImpl` for the same pattern.
    const padding_x: f32 = ctx.style.frame_padding[0];
    const padding_y: f32 = ctx.style.frame_padding[1];
    const inner_origin: Vec2 = .{ box.x + padding_x, box.y + padding_y };
    const inner_max: Vec2 = .{ box.x + box.width - padding_x, box.y + box.height - padding_y };
    const shifted_top: Vec2 = .{ inner_origin[0], inner_origin[1] - list_box.scroll_y };
    list_box.layout = .{
        .origin = inner_origin,
        .work_rect_max = inner_max,
        .cursor_pos = shifted_top,
        .cursor_max = shifted_top,
        .cursor_pos_prev_line = inner_origin,
        // Record the box's id + rect so item-state queries before
        // any selectable submission still see "the list box."
        .last_item_id = list_box_id,
        .last_item_rect = box,
        .last_item_hovered = pointInRect(ctx.input.mouse_pos, box),
    };

    // Push content clip rect AFTER the window-stack flip so the
    // clip is associated with this scope's draw stream.
    if (!ctx.eager_mode) {
        if (ctx.current_draw_list) |dl| {
            dl.pushClipRect(ctx.frame_arena.allocator(), box);
        }
    }

    return true;
}

// ============================================================================
// progress bar
// ============================================================================
// Two-rect render: full bar in `frame_bg`, filled portion in
// `button` accent (the same colour we use for slider grabs, so the
// progress visually rhymes with other interactive bars). Optional
// centred caption text overlays both rects.

// ============================================================================
// vertical slider
// ============================================================================
// Mirror of the horizontal slider but the bar lays out top-to-bottom
// inside `size`. Drag captures active_id; mouse_y delta translates
// to value delta along the bar's vertical span. Visually: bar
// outlined, current-value caption rendered ABOVE the bar (or below
// - ImGui renders the value above; we follow).
// Comptime dispatch identical to `slider` so the same anytype pointer
// works for f32, i32, u8, etc.

// ============================================================================
// text-entry numeric inputs (inputFloat / inputInt)
// ============================================================================
// Strategy: render a frame_bg box with the current value formatted
// via opts.fmt. On click → activate; user types into a per-widget
// edit buffer; on Enter (or focus-loss), parse the buffer, commit
// if valid, otherwise revert. Each widget owns a small fixed-size
// scratch buffer keyed by its id in `ctx.input_text_state`.
// We reuse the existing `input_text_state` machinery from 's
// inputText - same focus model, same active_id semantics. The
// only thing different is parse-on-commit instead of byte-stream
// pass-through.

/// Multi-component input: N narrow text-entry boxes side-by-side.
/// Only one is "focused" at a time (one shared edit buffer in
/// `ctx`), keyed by per-component id derived from `(parent, i)`.
fn inputArray(
    ctx: *UiContext,
    label: []const u8,
    arr_ptr: anytype,
    comptime T: type,
    comptime n: usize,
    opts: InputScalarOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const parent_id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const inner_spacing: f32 = 4;
    const text_size: Vec2 = measureTextS(ctx, label);
    const box_h: f32 = ctx.style.font_size + 2 * padding[1];
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const reserve_label: f32 = text_size[0] + ctx.style.item_spacing[0];
    const gaps: f32 = inner_spacing * float(n - 1);
    const default_total_w: f32 = @max(60 * float(n), inner_right - at[0] - reserve_label - gaps);
    var total_w: f32 = effectiveItemWidth(ctx, opts.width, default_total_w);
    if (total_w < 60 * float(n)) {
        total_w = 60 * float(n);
    }
    const box_w: f32 = total_w / float(n);

    var any_changed: bool = false;
    inline for (0..n) |i| {
        const id: Id = hashInt(parent_id, @intCast(i));
        const x: f32 = at[0] + (box_w + inner_spacing) * float(i);
        const box: Rectangle = .{ .x = x, .y = at[1], .width = box_w, .height = box_h };
        const sub_changed: bool = inputScalarInRect(ctx, w, id, box, &arr_ptr[i], T, opts);
        if (sub_changed) {
            any_changed = true;
        }
    }

    // Single label after the last box - applies to the whole widget.
    if (label.len > 0) {
        drawTextAtS(
            ctx,
            .{ at[0] + total_w + gaps + ctx.style.item_spacing[0], at[1] + padding[1] },
            label,
            Color.toWire(ctx.style.text),
        );
    }

    // Reset the last_item to the OUTER widget rect so IsItemHovered
    // covers the whole multi-component row (per-component inputs
    // overwrote last_item with their own rects).
    w.layout.last_item_id = parent_id;
    w.layout.last_item_rect = .{ .x = at[0], .y = at[1], .width = total_w + gaps + reserve_label, .height = box_h };
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, w.layout.last_item_rect);
    markItemPost(ctx, w, any_changed);
    markItemNavigable(ctx, w, parent_id);

    advanceLayout(
        w,
        .{ total_w + gaps + reserve_label, box_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return any_changed;
}

// ============================================================================
// colorButton - compact swatch with hover-tooltip
// ============================================================================
// Smaller-than-colorEdit footprint: a single rect filled with the
// supplied colour, framed by a 1px border. On hover, a tooltip
// shows the RGBA breakdown. Click returns true.

// ============================================================================
// imageButton - clickable variant of `image`
// ============================================================================
// Uses the existing `image` polymorphism (Texture / RenderTexture
// / raw GL ID) but adds button-style hit-testing + click-return on
// top. Hover renders a 1px border in `style.button_hovered` to
// signal interactivity.

// ============================================================================
// editStruct - comptime-reflective struct inspector
// ============================================================================
// `editStructImpl` walks the fields of `value_ptr.*` at comptime and
// dispatches each to a widget based on the field's type. Per-field
// options are pulled from the user-supplied `opts` struct via
// `@hasField` lookup - fields without a matching opts entry use
// defaults. Unknown opts fields are silently ignored so structs
// can grow without breaking call-sites.
// Numeric fields: if both `min` and `max` are present in the opts,
// dispatch to `slider`/`drag` (range-bounded). If neither, dispatch
// to `inputFloat`/`inputInt` (free-form text entry).
// Nested struct fields: render under a `treeNode(field_name)` so the
// user can collapse/expand subtrees. Recurse with empty opts
// nested-opts threading is a v2 feature.
// Enums: render as a `combo` over the enum's tags. The current
// value's tag index drives the combo's selected slot; selection
// writes back via `@enumFromInt(tag_index)`.

/// Inline-buffered string with a fixed byte capacity and a stored
/// length. The reflection inspector (P1.3) detects fields of this
/// type and routes them through `inputText`, giving the caller
/// mutable text editing without a heap allocator.
///
/// Layout: `[N]u8` buffer + `usize` length. Convertible to a
/// `[]const u8` slice via `as_slice()` and writable in bulk via
/// `set()`. Capacity is a comptime constant under the `capacity`
/// declaration; `__is_bounded_string` is the marker the inspector
/// checks for.
///
/// Usage:
///     state: struct {
///         player_name: z.ui.BoundedString(32) =.{},
///     } =.{},
///     //...
///     u.inspect("Settings", &state); // player_name → text field
///
/// Why a wrapping struct instead of `[N:0]u8`? zimr's `inputText`
/// takes a `(buf: []u8, len: *usize)` pair — the inspector can wire
/// directly to those without rescanning the buffer for a sentinel
/// every frame. And the public type is a clearer marker to a future
/// reader than a sentinel byte.
pub fn BoundedString(comptime N: usize) type {
    return struct {
        buf: [N]u8 = @splat(0),
        len: usize = 0,

        const Self = @This();
        pub const capacity: usize = N;
        /// Marker decl used by `editFieldDispatch` to detect this type.
        /// The value carries the capacity for tooling that wants it.
        pub const __is_bounded_string: usize = N;

        /// Borrow the populated bytes as a UTF-8 slice. Caller must
        /// not mutate through this slice — write via `set()`.
        pub fn asSlice(self: *const Self) []const u8 {
            return self.buf[0..self.len];
        }

        /// Replace contents with `s`, truncating to capacity if
        /// longer. Returns the number of bytes written.
        pub fn set(self: *Self, s: []const u8) usize {
            const n: usize = @min(s.len, N);
            @memcpy(self.buf[0..n], s[0..n]);
            self.len = n;
            return n;
        }
    };
}

/// Comptime check: can `std.mem.zeroes(T)` succeed for this T?
/// Used by `editFieldDispatch` to decide whether to expose mutable
/// "toggle off/on" UI for Optional and Union fields - if we can't
/// zero-init the wrapped/payload type, mutation falls back to
/// display-only.
///
/// The matrix mirrors `std.mem.zeroes`:
///   - int/float/bool/void/enum/optional → yes (the obvious cases)
///   - array/vector → yes if the element type is zero-init-able
///   - struct → yes if every field is zero-init-able (recursive)
///   - pointer.slice/.c → yes (empty slice / null)
///   - pointer.one/.many → yes ONLY if allowzero (Style.font is the
///     real-world counterexample: ?*const Font won't zero-init)
///   - union → no (std.mem.zeroes is @compileError for non-extern
///     unions; we don't bother detecting the extern-layout exception)
///   - anything else → no
fn canZeroInit(comptime T: type) bool {
    return switch (@typeInfo(T)) {
        .int, .float, .bool, .void, .@"enum" => true,
        .optional => true,
        .array => |a| canZeroInit(a.child),
        .vector => |v| canZeroInit(v.child),
        .@"struct" => |s| blk: {
            inline for (s.field_types) |f_type| {
                if (!canZeroInit(f_type)) {
                    break :blk false;
                }
            }
            break :blk true;
        },
        .pointer => |p| switch (p.size) {
            .slice, .c => true,
            .one, .many => p.attrs.@"allowzero",
        },
        else => false,
    };
}

fn editFloatField(
    ctx: *UiContext,
    label: []const u8,
    comptime T: type,
    field_ptr: *T,
    field_opts: anytype,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    const has_min: bool = comptime @hasField(@TypeOf(field_opts), "min");
    const has_max: bool = comptime @hasField(@TypeOf(field_opts), "max");
    const has_fmt: bool = comptime @hasField(@TypeOf(field_opts), "fmt");
    const fmt: []const u8 = if (has_fmt) field_opts.fmt else "{d:.3}";

    const has_range: bool = has_min and has_max;
    if (has_range) {
        // Slider when both bounds are known. Cast user-supplied
        // min/max (often comptime ints/floats) to f64 for SliderOpts.
        const min_f64: f64 = @floatCast(@as(f64, field_opts.min));
        const max_f64: f64 = @floatCast(@as(f64, field_opts.max));
        return ui.slider(label, field_ptr, .{
            .min = min_f64,
            .max = max_f64,
            .fmt = fmt,
        });
    }
    // Free-form text-entry input when no range.
    if (T == f32) {
        return ui.inputFloat(label, field_ptr, .{ .fmt = fmt });
    }
    // Other float widths (f64 etc.): degrade gracefully via drag.
    return ui.drag(label, field_ptr, .{ .fmt = fmt });
}

fn editIntField(
    ctx: *UiContext,
    label: []const u8,
    comptime T: type,
    field_ptr: *T,
    field_opts: anytype,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    const has_min: bool = comptime @hasField(@TypeOf(field_opts), "min");
    const has_max: bool = comptime @hasField(@TypeOf(field_opts), "max");
    const has_fmt: bool = comptime @hasField(@TypeOf(field_opts), "fmt");
    const fmt: []const u8 = if (has_fmt) field_opts.fmt else "{d}";

    const has_range: bool = has_min and has_max;
    if (has_range) {
        const min_f64: f64 = float64(@as(i64, @intCast(field_opts.min)));
        const max_f64: f64 = float64(@as(i64, @intCast(field_opts.max)));
        return ui.slider(label, field_ptr, .{
            .min = min_f64,
            .max = max_f64,
            .fmt = fmt,
        });
    }
    if (T == i32) {
        return ui.inputInt(label, field_ptr, .{ .fmt = fmt });
    }
    return ui.drag(label, field_ptr, .{ .fmt = fmt });
}

fn editEnumField(
    ctx: *UiContext,
    label: []const u8,
    comptime EnumT: type,
    field_ptr: *EnumT,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    const enum_info = @typeInfo(EnumT).@"enum";
    // Build a comptime []const []const u8 of tag names.
    const names: [enum_info.field_names.len][]const u8 = comptime blk: {
        var arr: [enum_info.field_names.len][]const u8 = undefined;
        for (enum_info.field_names, 0..) |f_name, i| {
            arr[i] = f_name;
        }
        break :blk arr;
    };
    // Find the current tag's index. Tags may have non-contiguous
    // values; we scan rather than cast-as-int.
    var current_idx: i32 = 0;
    inline for (enum_info.field_names, 0..) |f_name, i| {
        if (field_ptr.* == @field(EnumT, f_name)) {
            current_idx = @intCast(i);
        }
    }
    const previous_idx: i32 = current_idx;
    const items_slice: []const []const u8 = &names;
    _ = ui.combo(label, &current_idx, items_slice, .{});
    if (current_idx == previous_idx) {
        return false;
    }
    // Selection changed - write back the corresponding tag.
    inline for (enum_info.field_names, 0..) |f_name, i| {
        if (current_idx == @as(i32, @intCast(i))) {
            field_ptr.* = @field(EnumT, f_name);
        }
    }
    return true;
}

/// edit a `Color` (the raylib u8 RGBA struct) via a
/// `colorEdit` widget. Internally converts u8 → [4]f32 → u8 around
/// the call so the widget sees floats but the caller's struct stays
/// in u8 form.
fn editColorField(
    ctx: *UiContext,
    label: []const u8,
    field_ptr: *Color,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    var rgba_f: [4]f32 = .{
        float(field_ptr.r) / 255.0,
        float(field_ptr.g) / 255.0,
        float(field_ptr.b) / 255.0,
        float(field_ptr.a) / 255.0,
    };
    const changed: bool = ui.colorEdit(label, &rgba_f, .{});
    if (changed) {
        field_ptr.r = @round(clamp(rgba_f[0], 0, 1) * 255);
        field_ptr.g = @round(clamp(rgba_f[1], 0, 1) * 255);
        field_ptr.b = @round(clamp(rgba_f[2], 0, 1) * 255);
        field_ptr.a = @round(clamp(rgba_f[3], 0, 1) * 255);
    }
    return changed;
}

/// edit a `Vec2` (two floats) as a side-by-side row
/// of two inputFloats labelled "label.x" and "label.y".
fn editVector2Field(
    ctx: *UiContext,
    label: []const u8,
    field_ptr: *Vec2,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    // Compose per-component labels so each inputFloat gets a unique
    // ID. 16-byte buffer is plenty for the typical short field names
    // appearing in Style (e.g. "frame_padding.x").
    var x_label_buf: [64]u8 = undefined;
    var y_label_buf: [64]u8 = undefined;
    const x_label: []const u8 = bufPrint(&x_label_buf, "{s}.x", .{label}) catch x_label_buf[0..0];
    const y_label: []const u8 = bufPrint(&y_label_buf, "{s}.y", .{label}) catch y_label_buf[0..0];
    // Zig 1245 types `&vec[i]` as `*align(...:...) f32` (vector-element
    // pointer) which won't coerce to `*f32`. Edit scalar copies, then write
    // the vector back component-wise.
    var vx: f32 = field_ptr[0];
    var vy: f32 = field_ptr[1];
    const x_changed: bool = ui.inputFloat(x_label, &vx, .{});
    const y_changed: bool = ui.inputFloat(y_label, &vy, .{});
    field_ptr[0] = vx;
    field_ptr[1] = vy;
    return x_changed or y_changed;
}

/// — edit a `BoundedString(N)` via the existing `inputText`
/// widget. Trusts the wrapping type's `__is_bounded_string` marker:
/// callers reach here only via the dispatcher's struct branch.
/// `inputText` writes through `field_ptr.buf` + `field_ptr.len`
/// directly — no copying, no rescan.
fn editBoundedStringField(
    ctx: *UiContext,
    label: []const u8,
    field_ptr: anytype,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    return ui.inputText(label, &field_ptr.buf, &field_ptr.len, .{});
}

fn editArrayField(
    ctx: *UiContext,
    label: []const u8,
    comptime ArrayT: type,
    comptime arr: anytype,
    field_ptr: *ArrayT,
    field_opts: anytype,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    // Special-case: [3]f32 / [4]f32 → colorEdit (when opts.is_color
    // is true OR the field name contains "color").
    const is_color_explicit: bool = comptime blk: {
        if (@hasField(@TypeOf(field_opts), "is_color")) {
            break :blk field_opts.is_color;
        }
        break :blk false;
    };
    const is_color_by_name: bool = std.mem.indexOf(u8, label, "color") != null;
    const looks_like_color: bool = is_color_explicit or is_color_by_name;
    const is_color3: bool = arr.child == f32 and arr.len == 3 and looks_like_color;
    if (is_color3) {
        return ui.colorEdit(label, field_ptr, .{});
    }
    const is_color4: bool = arr.child == f32 and arr.len == 4 and looks_like_color;
    if (is_color4) {
        return ui.colorEdit(label, field_ptr, .{});
    }

    // Generic array: one widget per element under a tree node so a
    // wide array doesn't blow out vertical space at the top level.
    if (!ui.treeNode(label, .{})) {
        return false;
    }
    defer ui.treePop();

    var any_changed: bool = false;
    inline for (0..arr.len) |i| {
        var idx_buf: [16]u8 = undefined;
        const elem_label: []const u8 = bufPrint(&idx_buf, "[{d}]", .{i}) catch idx_buf[0..0];
        if (editFieldDispatch(ctx, elem_label, arr.child, &field_ptr[i], field_opts)) {
            any_changed = true;
        }
    }
    return any_changed;
}

fn editFieldDispatch(
    ctx: *UiContext,
    label: []const u8,
    comptime FieldT: type,
    field_ptr: anytype,
    field_opts: anytype,
) bool {
    const ui: Ui = .{ .ctx = ctx };
    switch (@typeInfo(FieldT)) {
        .bool => return ui.checkbox(label, field_ptr),
        .float => return editFloatField(ctx, label, FieldT, field_ptr, field_opts),
        .int => return editIntField(ctx, label, FieldT, field_ptr, field_opts),
        .@"enum" => return editEnumField(ctx, label, FieldT, field_ptr),
        .@"struct" => {
            // special-case `Color` and `Vec2` to widgets
            // that match their semantics, instead of recursing into a
            // tree-of-component-fields. Order matters here - these
            // checks happen BEFORE the generic struct → treeNode path.
            if (FieldT == Color) {
                return editColorField(ctx, label, field_ptr);
            }
            if (FieldT == Vec2) {
                return editVector2Field(ctx, label, field_ptr);
            }
            // P1.3: BoundedString(N) — inline-buffered editable string.
            // Detected by the `__is_bounded_string` marker decl set by
            // the BoundedString generic. Routes to `inputText` over the
            // wrapped (buf, len) pair.
            if (comptime @hasDecl(FieldT, "__is_bounded_string")) {
                return editBoundedStringField(ctx, label, field_ptr);
            }
            // Recursive editor under a tree node so the user can
            // collapse subtrees. Returns true if anything inside
            // changed this frame.
            if (ui.treeNode(label, .{})) {
                defer ui.treePop();
                return editStructImpl(ctx, field_ptr, .{});
            }
            return false;
        },
        .array => |arr| return editArrayField(ctx, label, FieldT, arr, field_ptr, field_opts),
        .pointer => |p| {
            // []const u8 → display only (read-only string view).
            const is_const_byte_slice: bool =
                p.size == .slice and p.child == u8 and p.attrs.@"const";
            if (is_const_byte_slice) {
                ui.text("{s}: {s}", .{ label, field_ptr.* });
                return false;
            }
            ui.text("{s}: <unsupported pointer type {s}>", .{ label, @typeName(FieldT) });
            return false;
        },
        .optional => |opt| {
            // P1.1: edit an Optional by combining a checkbox "active"
            // toggle with a recursive editor over the inner value
            // when present. Initial value when toggling from null
            // to set is `std.mem.zeroes(opt.child)` - works for most
            // common types but FAILS at comptime for wrapped types
            // that contain non-allowzero pointers (e.g.
            // `?*const Font`). Gate the toggle path on
            // `canZeroInit(opt.child)`; fall back to display-only +
            // recursive-when-set when the wrapped type can't be
            // synthesized.
            if (comptime canZeroInit(opt.child)) {
                var present: bool = field_ptr.* != null;
                if (ui.checkbox(label, &present)) {
                    if (present) {
                        field_ptr.* = std.mem.zeroes(opt.child);
                    } else {
                        field_ptr.* = null;
                    }
                    return true;
                }
                if (field_ptr.*) |*inner| {
                    ui.indent();
                    defer ui.unindent();
                    return editFieldDispatch(ctx, label, opt.child, inner, field_opts);
                }
                return false;
            } else {
                // Wrapped type can't be zero-init'd. Show the toggle
                // state read-only; recurse into the value when set so
                // the user can still edit individual sub-fields. No
                // ability to toggle null → set from the inspector.
                if (field_ptr.*) |*inner| {
                    ui.text("{s}: <set>", .{label});
                    ui.indent();
                    defer ui.unindent();
                    return editFieldDispatch(ctx, label, opt.child, inner, field_opts);
                }
                ui.text("{s}: null", .{label});
                return false;
            }
        },
        .@"union" => |un| {
            // P1.1: edit a tagged union by combining a combo over
            // the tag set with a recursive editor on the active
            // variant. Switching tags re-initializes the payload
            // via `std.mem.zeroes` - so variants whose payload type
            // can't be zero-init'd appear as selectable-but-disabled
            // in the combo (user can SEE them but can't switch in
            // from the inspector). Recursion on the active variant
            // works regardless.
            //

            // Untagged unions are fully display-only: without a tag
            // we can't know which variant is active.
            const TagT: type = un.tag_type orelse {
                ui.text("{s}: <untagged union>", .{label});
                return false;
            };
            const current: TagT = meta.activeTag(field_ptr.*);
            var any_changed: bool = false;

            const preview: []const u8 = @tagName(current);
            if (ui.beginCombo(label, preview, .{})) {
                defer ui.endCombo();
                inline for (un.fields) |uf| {
                    const tag_val: TagT = @field(TagT, uf.name);
                    const selected: bool = (tag_val == current);
                    if (comptime canZeroInit(uf.type)) {
                        if (ui.selectable(uf.name, selected, .{}) and !selected) {
                            field_ptr.* = @unionInit(FieldT, uf.name, std.mem.zeroes(uf.type));
                            any_changed = true;
                        }
                    } else {
                        // Variant payload can't be zero-init'd; show
                        // the name as a disabled entry so the user
                        // sees the full tag set without an active
                        // selectable.
                        ui.textDisabled("{s}", .{uf.name});
                    }
                }
            }

            // Recurse on the active variant's payload. void variants
            // have nothing to edit; skip them comptime.
            ui.indent();
            defer ui.unindent();
            inline for (un.fields) |uf| {
                if (uf.type != void) {
                    const tag_val: TagT = @field(TagT, uf.name);
                    if (tag_val == current) {
                        const payload_ptr: *uf.type = &@field(field_ptr.*, uf.name);
                        if (editFieldDispatch(ctx, uf.name, uf.type, payload_ptr, .{})) {
                            any_changed = true;
                        }
                    }
                }
            }

            return any_changed;
        },
        else => {
            ui.text("{s}: <unsupported type {s}>", .{ label, @typeName(FieldT) });
            return false;
        },
    }
}

// ============================================================================
// editArrayList - comptime-reflective list editor
// ============================================================================
// Wrapper around editStruct that handles the dynamic-list case:
//   - "Add" button appends a default-initialized T
//   - per-element row dispatches the same widget matrix as editStruct
//   - "Remove" button deletes that element via orderedRemove
// The list's allocator comes from `ctx.gpa` so callers don't have to
// pass it through. `pushIdInt(idx)` scopes IDs across iterations so
// the per-element "Remove" button on row 3 doesn't collide with the
// one on row 4 (now that widgets honour `id_stack` via `widgetId`).

// ============================================================================
// layout persistence - ZON serialize/apply of window state
// ============================================================================
// Each window in `ctx.windows` has a stable hashed `id` (computed from
// its title) and per-frame mutable state: `pos`, `size`, `scroll_y`,
// `user_resized`. Persisting these across launches lets users keep a
// dragged/resized layout.
// Format is ZON (Zig Object Notation) - Zig's native data format.
// The on-disk representation is human-readable, hand-editable, and
// diff-friendly. Schema:
//     .{
//         .version = 2,
//         .windows =.{
//             .{
//                 .title = "settings",
//                 .pos =.{ 100, 200 },
//                 .size =.{ 320, 240 },
//                 .scroll_y = 0,
//                 .user_resized = false,
//             },
//             ...
//         },
//     }
// Why ZON over a hand-rolled binary container:
//   - Readable + hand-editable for debugging or recovery
//   - Adding new fields with defaults doesn't break old files
//   - No endianness, magic-byte, or offset-arithmetic bugs
//   - About 5-10x larger than binary, but layout files are tiny
//     (<10KB even for 50+ windows) so the size hit is irrelevant
// Compared to the previous binary format, ZON also stores the window
// TITLE rather than its hash - humans can read a layout file and
// recognize which window each entry refers to. The title is rehashed
// at load time to recover the id used as the lookup key in
// `ctx.windows`.

/// Current schema version. Bumped on incompatible field changes.
/// Adding a new field with a default does NOT require a bump (ZON
/// tolerates missing fields and uses the default). Bump when:
///   - Removing a field that older code requires
///   - Renaming a field
///   - Changing a field's type semantics
const layout_schema_version: u32 = 2;

const WindowLayoutEntry = struct {
    /// Window title used to derive the runtime `id` via `hashStr(0,
    /// title)`. Persisted as a string (not the raw hash) so layout
    /// files are human-readable.
    title: []const u8,
    /// Top-left corner in screen pixels.
    pos: Vec2,
    /// Outer size in pixels (includes title bar).
    size: Vec2,
    /// Vertical scroll offset. Defaulted to 0 so older files
    /// missing this field still load.
    scroll_y: f32 = 0,
    /// Horizontal scroll offset. Only meaningful when the
    /// window's `horizontal_scrollbar` flag was set; otherwise
    /// stays 0 on save and is ignored on load. Defaulted so older
    /// files still parse cleanly.
    scroll_x: f32 = 0,
    /// Whether the user manually resized this window (suppresses
    /// auto-fit grow).
    user_resized: bool = false,
};

const LayoutFile = struct {
    /// Schema version - see `layout_schema_version`.
    version: u32 = layout_schema_version,
    /// All windows whose state should be persisted.
    windows: []const WindowLayoutEntry = &.{},
};

fn serializeLayoutImpl(
    ctx: *UiContext,
    gpa: Allocator,
) ![]u8 {
    // Build the slice of entries from the live ctx.windows map.
    // Skip entries with an empty name (these are stub windows
    // created by a previous applyLayout that haven't been adopted
    // by a real `f.ui.window(...)` call yet - they have no meaningful
    // title to write).
    var entries: ArrayList(WindowLayoutEntry) = .empty;
    defer entries.deinit(gpa);

    var it: @TypeOf(ctx.windows.iterator()) = ctx.windows.iterator();
    while (it.next()) |entry| {
        const w: *Window = entry.value_ptr.*;
        const title_text: []const u8 = w.name();
        if (title_text.len == 0) {
            continue;
        }
        try entries.append(gpa, .{
            .title = title_text,
            .pos = w.pos,
            .size = w.size,
            .scroll_y = w.scroll_y,
            .scroll_x = w.scroll_x,
            .user_resized = w.user_resized,
        });
    }

    const file: LayoutFile = .{
        .version = layout_schema_version,
        .windows = entries.items,
    };

    // Stringify into an Allocating writer, then take the owned slice.
    var alloc_writer: std.Io.Writer.Allocating = .init(gpa);
    errdefer alloc_writer.deinit();
    try std.zon.stringify.serialize(file, .{}, &alloc_writer.writer);
    return alloc_writer.toOwnedSlice();
}

fn applyLayoutImpl(
    ctx: *UiContext,
    bytes: []const u8,
) !void {
    // ZON parser wants a sentinel-terminated source. Allocate a
    // temporary copy with a trailing null byte. Freed before we
    // return either way.
    const source: [:0]u8 = try dupeZ(ctx.gpa, bytes);
    defer ctx.gpa.free(source);

    // Parse with `fromSliceAlloc` because LayoutFile contains slices
    // ([]const u8 and []const WindowLayoutEntry). Use `free` to
    // release the backing memory after we've copied the data we
    // want into ctx.windows.
    const file: LayoutFile = std.zon.parse.fromSliceAlloc(
        LayoutFile,
        ctx.gpa,
        source,
        null,
        .{},
    ) catch {
        return error.LayoutFormatMismatch;
    };
    defer std.zon.parse.free(ctx.gpa, file);

    // Reject mismatched schema versions cleanly. Tests / users get a
    // typed error rather than wonky-but-loaded windows.
    const version_ok: bool = file.version == layout_schema_version;
    if (!version_ok) {
        return error.LayoutFormatMismatch;
    }

    // Apply each entry. Hash the title to recover the runtime id.
    for (file.windows) |entry| {
        const id: Id = hashStr(0, entry.title);
        if (ctx.windows.get(id)) |existing| {
            existing.pos = entry.pos;
            existing.size = entry.size;
            existing.scroll_y = entry.scroll_y;
            existing.scroll_x = entry.scroll_x;
            existing.user_resized = entry.user_resized;
            continue;
        }
        // Stub: pre-allocate a Window keyed by id + title. When the
        // user later calls `f.ui.window(entry.title,...)` the
        // findOrCreateWindow lookup hits this entry and adopts the
        // saved geometry. Copy the title into name_buf so a later
        // serialize() round-trips identically.
        const stub: *Window = try ctx.gpa.create(Window);
        stub.* = .{
            .id = id,
            .pos = entry.pos,
            .size = entry.size,
            .scroll_y = entry.scroll_y,
            .scroll_x = entry.scroll_x,
            .user_resized = entry.user_resized,
        };
        const cap: usize = @min(entry.title.len, stub.name_buf.len);
        @memcpy(stub.name_buf[0..cap], entry.title[0..cap]);
        stub.name_len = cap;
        try ctx.windows.put(ctx.gpa, id, stub);
    }
}

// ============================================================================
// showDemoWindow - flagship demo
// ============================================================================
// Mirrors ImGui's `ShowDemoWindow` - one call submits a window with
// a tab bar walking every widget category in the port. Self-
// documenting: each tab is a small example the user can read for
// "how do I use this widget?". Doubles as a smoke test - if one
// of the showXxxTab fns panics, every nightly catches it.
// State lives in a `DemoState` struct the caller owns. Default-
// initialized is fine; values evolve as the user interacts.

pub const DemoMode = enum { off, easy, medium, hard };

pub const DemoState = struct {
    // Widgets tab.
    widgets_button_count: u32 = 0,
    widgets_check_a: bool = false,
    widgets_check_b: bool = true,
    widgets_slider_f: f32 = 0.5,
    widgets_drag_f: f32 = 1.0,
    widgets_input_int: i32 = 42,
    widgets_input_float: f32 = 3.14,
    widgets_combo_idx: i32 = 0,
    widgets_listbox_idx: i32 = 0,
    widgets_color: [4]f32 = .{ 0.4, 0.7, 1.0, 1.0 },
    widgets_color_swatch: Color = .{ .r = 200, .g = 100, .b = 50, .a = 255 },
    widgets_progress: f32 = 0.6,
    widgets_text_buf: [64]u8 = std.mem.zeroes([64]u8),
    widgets_text_len: usize = 0,
    widgets_mode: DemoMode = .easy,
    // Layout tab.
    layout_indent_a: bool = false,
    layout_indent_b: bool = true,
    layout_show_separator: bool = true,
    // About tab - no state needed.
};

const widget_combo_options = [_][]const u8{ "Apple", "Banana", "Cherry", "Durian" };
const widget_listbox_options = [_][]const u8{ "First", "Second", "Third", "Fourth", "Fifth" };

fn showWidgetsTab(self: Ui, state: *DemoState) void {
    self.text("Buttons & basic actions", .{});
    if (self.button("Click me", .{})) {
        state.widgets_button_count += 1;
    }
    self.sameLine(.{});
    self.text("(clicked {d} times)", .{state.widgets_button_count});
    _ = self.checkbox("Check A", &state.widgets_check_a);
    _ = self.checkbox("Check B", &state.widgets_check_b);

    self.separator();
    self.text("Sliders & drags", .{});
    _ = self.slider("Slider f32", &state.widgets_slider_f, .{ .min = 0, .max = 1, .fmt = "{d:.2}" });
    _ = self.drag("Drag f32", &state.widgets_drag_f, .{ .speed = 0.05, .fmt = "{d:.2}" });

    self.separator();
    self.text("Numeric text entry", .{});
    _ = self.inputInt("Int input", &state.widgets_input_int, .{});
    _ = self.inputFloat("Float input", &state.widgets_input_float, .{ .fmt = "{d:.3}" });

    self.separator();
    self.text("Combo & list box", .{});
    _ = self.combo("Combo", &state.widgets_combo_idx, &widget_combo_options, .{});
    _ = self.listBox("List", &state.widgets_listbox_idx, &widget_listbox_options);

    self.separator();
    self.text("Color", .{});
    _ = self.colorEdit("Color (RGBA)", &state.widgets_color, .{});
    _ = self.colorButton("Swatch", &state.widgets_color_swatch, .{ 32, 32 });

    self.separator();
    self.text("Progress & text", .{});
    self.progressBar(state.widgets_progress, .{ 0, 14 }, "");
    _ = self.inputText("Text", &state.widgets_text_buf, &state.widgets_text_len, .{});
}

// ============================================================================
// Widget: beginChild / endChild - clipped sub-window
// ============================================================================
// A child window is a full `Window`:
// `beginChild` allocates or fetches a persistent `Window` from
// `ctx.windows`, keyed by hashed child id, and pushes it onto the
// window stack so it becomes `current_window` for the scope's
// duration. Widget code inside the scope reads/writes that Window's
// `layout`; the parent's `layout` is untouched until `endChild`
// advances it past the child's outer rect.
// Why each child IS a Window (vs the old in-place state push):
//  - **Per-child persistent state** - scroll position,
//    focus, future dock-node assignment, future per-child storage.
//    Each lives on the child's own Window, no side tables needed.
//  - **Uniform scope math** - there's no "are we in a child" branch
//    anywhere. `current_window.layout` IS the active scope. Cursor,
//    work_rect, auto-fill, region-avail all read from one place
//    regardless of nesting depth.
//  - **Docking-ready** - a child can be detached into a top-level
//    window with zero structural change because children are
//    structurally identical to top-level windows. unblocked.
//  - **Imgui parity** - imgui's `BeginChildEx` delegates to `Begin()`
//    which allocates a real `ImGuiWindow*`. We do the same with our
//    `findOrCreateChildWindow`. See
//    `src/notes/architecture-imgui-vs-zimr.md` for the full
//    discussion.
// Drawing-wise: the child's content goes into the SAME draw list as
// the parent (currently - docking will introduce floating
// children with their own draw lists), bracketed by a `push_clip` /
// `pop_clip` pair that clips it to the child's outer rect. This
// matches imgui's default for non-floating children.
// The clip is what makes content beyond the child's bounds disappear
// at the GL scissor. At replay, `push_clip` calls
// `shapes2d.beginScissorMode`; widgets emitted inside the
// scope render only inside the scissor box.

/// `beginChild` core rewrite.
/// Each `beginChild` scope now allocates (or fetches from
/// `ctx.windows`) a real persistent `Window` keyed by the hashed
/// child id. That child Window becomes the current window for the
/// duration of the scope; widgets read `ctx.current_window` and
/// hit `child.layout` rather than mutating the parent's.
/// Why this is the right shape:
/// - Per-child persistent state (scroll offset, focus, future dock
///   node assignment) lives naturally on the child's `Window`, no
///   side-tables needed.
/// - Auto-fill, region-avail, work_rect math is uniform - no
///   "are we in a child" check anywhere. The cursor in
///   `child.layout` is the cursor, full stop.
/// - Docking becomes possible: a child can be detached
///   into a separate top-level window with no structural change,
///   because children ARE windows.
/// - Matches imgui's `BeginChildEx` (imgui.cpp:6419) which
///   internally calls `Begin()` to make the child a full window.
///   See `src/notes/architecture-imgui-vs-zimr.md` for the deep
///   discussion.
/// Before this rewrite, `beginChild` pushed the parent's
/// `LayoutScope` onto a `child_stack` and mutated `parent.layout`
/// in place; `endChild` restored. Every per-scope feature had to
/// be added to `LayoutScope` + `ChildState`'s save/restore. Three
/// of the recent six bugs (sameLine cursor, layout origin, right-
/// pane spillover) were direct consequences of that bolt-on
/// pattern - see the architecture tutorial.
fn beginChildImpl(
    ctx: *UiContext,
    str_id: []const u8,
    size: Vec2,
    flags: ChildFlags,
) bool {
    const parent: *Window = ctx.current_window orelse return false;

    // ---- ID scope -------------------------------------------------
    // Hash the child's str_id into the parent's ID stack so widgets
    // inside don't collide with identically-labeled widgets outside,
    // and so the same logical child (called from the same nesting
    // depth) finds the same persistent `Window` across frames.
    const id_top: Id = if (ctx.id_stack.len > 0)
        ctx.id_stack.items[ctx.id_stack.len - 1]
    else
        0;
    const child_id: Id = hashStr(id_top, str_id);

    // ---- Resolve child's outer rect from PARENT's cursor + size
    // The child anchors at the parent's current layout cursor and
    // extends down/right by `size`. Zero or negative dimensions
    // auto-fill: width fills to the parent's right edge (its
    // `work_rect_max[0]`), height to a default of 100px (a
    // reasonable log/list viewport). Any explicit size is clamped
    // DOWN to fit within the parent's scope - children physically
    // cannot escape their parent, even when user math is wrong
    //.
    const at: Vec2 = resolveCursor(parent, ctx.style.item_spacing[0]);
    const max_w: f32 = @max(0, parent.layout.work_rect_max[0] - at[0]);
    const max_h: f32 = @max(0, parent.layout.work_rect_max[1] - at[1]);
    var actual_size: Vec2 = size;
    if (actual_size[0] <= 0) {
        actual_size[0] = max_w;
    }
    if (actual_size[1] <= 0) {
        actual_size[1] = 100;
    }
    if (actual_size[0] > max_w) {
        actual_size[0] = max_w;
    }
    if (actual_size[1] > max_h) {
        actual_size[1] = max_h;
    }
    if (actual_size[0] < 0) {
        actual_size[0] = 0;
    }
    if (actual_size[1] < 0) {
        actual_size[1] = 0;
    }

    const child_rect: Rectangle = .{
        .x = at[0],
        .y = at[1],
        .width = actual_size[0],
        .height = actual_size[1],
    };

    // ---- Fetch (or allocate) the persistent child Window ----------
    const child: *Window = findOrCreateChildWindow(ctx, child_id);
    // Re-stamp the per-frame fields - pos/size move every frame
    // (children re-anchor relative to parent's cursor), parent_id
    // can change if the child is called from a different nesting
    // location across frames (rare but legal - same str_id in two
    // places creates two child Windows because they hash to
    // different IDs via the parent ID stack).
    child.pos = .{ child_rect.x, child_rect.y };
    child.size = .{ child_rect.width, child_rect.height };
    child.parent_id = parent.id;
    child.last_frame_active = ctx.frame_count;

    // ---- Optional border (drawn on PARENT's draw list, BEFORE the
    //      child's clip-rect push so the border shows on the edges)
    if (flags.border) {
        drawRect(ctx, child_rect, Color.toWire(ctx.style.border));
    }

    // ---- Push ID + window stack -----------------------------------
    // Push BEFORE setting current_window so a failure inside the
    // push leaves invariants intact (parent still current).
    ctx.id_stack.append(child_id) catch return false;
    ctx.window_stack.append(child) catch {
        _ = ctx.id_stack.pop();
        return false;
    };
    ctx.current_window = child;

    // ---- Push clip rect for the child's content
    // The parent's draw list is still the active one - children
    // submit into the parent's draw stream via clip-rect stacking,
    // matching imgui's default behavior for non-floating children.
    // (Floating children, e.g. via docking, would get their own
    // draw list. work.)
    if (!ctx.eager_mode) {
        if (ctx.current_draw_list) |dl| {
            dl.pushClipRect(ctx.frame_arena.allocator(), child_rect);
        }
    }

    // ---- Install the child's fresh LayoutScope
    // Padding semantics: inner padding is
    // applied when EITHER `border` (forces padding so the border
    // doesn't crowd content) OR `always_use_window_padding` (explicit
    // request for breathing room) is set. Pad amount: the style's
    // window_padding. Without either flag, content starts at the
    // child's exact corner (matches imgui's `ImGuiChildFlags_None`
    // semantics - non-bordered children are tight by default).
    const use_padding: bool = flags.border or flags.always_use_window_padding;
    const pad_x: f32 = if (use_padding) ctx.style.window_padding[0] else 0;
    const pad_y: f32 = if (use_padding) ctx.style.window_padding[1] else 0;
    const inner_origin: Vec2 = .{ child_rect.x + pad_x, child_rect.y + pad_y };
    const inner_max: Vec2 = .{
        child_rect.x + child_rect.width - pad_x,
        child_rect.y + child_rect.height - pad_y,
    };
    const shifted_top: Vec2 = .{ inner_origin[0], inner_origin[1] - child.scroll_y };
    child.layout = .{
        .origin = inner_origin,
        .work_rect_max = inner_max,
        .cursor_pos = shifted_top,
        .cursor_max = shifted_top,
        .cursor_pos_prev_line = inner_origin,
    };

    return true;
}

// ============================================================================
// Docking - `dockSpace` widget
// ============================================================================
// `dockSpace` is the user-facing entry point for the docking
// system. It looks up (or creates) a root `DockNode` keyed by
// `str_id` under the current window's id stack, reserves its
// `size` worth of layout space inside the calling window, and
// returns the node id. Callers stash the id to feed
// `dockBuilderDockWindow` / `dockBuilderSplitNode` for default
// layouts.
// What renders today:
//   - A single 1-pixel-thick outline around the dockspace rect
//     so the user can see the reserved region.
//   - A faint "DockSpace" hint label in the corner (so a fresh
//     dockspace doesn't look like a rendering glitch).
// What 5.5c will add:
//   - Iterating the tree, rendering each leaf's tab bar + content.
//   - Splitter handles between split siblings.

/// tear a docked window out of its leaf
/// and reposition it under the cursor so the title-bar drag
/// flow picks it up on the next frame. Used by the drag-detach
/// path - the user grabbed a docked tab and pulled past the
/// threshold; the window becomes floating mid-drag.
/// Differs from `undockSingleWindow` in one important way:
///   - `undockSingleWindow` RESTORES pre_dock_pos/size (the
///     window pops back to where it was before being docked).
///   - This function DISCARDS pre_dock_pos (we want the
///     window under the cursor, not at the old float spot).
///     Pre_dock_size is preserved as the floating size - a
///     reasonable default for the now-floating window.
/// After this returns:
///   - The window's `dock_node_id` is null.
///   - `w.pos` is positioned so the title bar lands under the
///     cursor (cursor is roughly horizontal-center of the
///     title bar, vertical center of the title-bar strip).
///   - `ctx.dock.dragging_window = window_id` so the
///     5.5d.iv drag-to-dock overlay renders this frame.
///   - `ctx.active_id = 0` so next frame's title-bar drag
///     handler can claim it (it checks `mp_in_title and md
///     and active_id == 0`).
fn detachAndStartDrag(ctx: *UiContext, window_id: Id) void {
    const w: *Window = ctx.windows.get(window_id) orelse return;
    if (w.dock_node_id == null) {
        return;
    }

    const mp: Vec2 = ctx.input.mouse_pos;

    // Recover the floating size to use AFTER detach. If we
    // have a pre-dock stash, use that - it's the size the
    // window had before docking. Otherwise keep the current
    // (leaf-sized) value, which the user can resize later.
    const restored_size: Vec2 = w.pre_dock_size orelse w.size;
    w.pre_dock_pos = null;
    w.pre_dock_size = null;
    w.dock_node_id = null;
    w.size = restored_size;

    _ = undockWindow(&ctx.dock, ctx.gpa, window_id);

    // Position so the title bar lands under the cursor. Half
    // the floating size to the LEFT of cursor (horizontal
    // center); half the title-bar height ABOVE cursor
    // (vertical center of title strip).
    const title_h: f32 = ctx.style.title_bar_height;
    w.pos = .{
        mp[0] - restored_size[0] * 0.5,
        mp[1] - title_h * 0.5,
    };

    // Hand off to the title-bar drag flow on the NEXT frame.
    // The user is still holding the mouse. Claim the title-bar drag id NOW so the
    // continue-drag (keyed on `active_id == drag_id` + mouse-held) picks up
    // seamlessly. The press handler CANNOT re-claim it: it only fires on a FRESH
    // click (`mc`), and the button is already held — that mismatch was why a
    // detach-then-redock drag silently dropped and never docked. Anchor at the
    // cursor (w.pos was set above so the title bar sits under the cursor).
    const drag_id: Id = widgetId(ctx, w, "##drag_title");
    ctx.active_id = drag_id;
    ctx.active_id_just_activated = true;
    ctx.active_id_press_x = mp[0] - w.pos[0];
    ctx.active_id_press_value = @floatCast(mp[1] - w.pos[1]);
    ctx.active_id_press_screen = mp;
    ctx.dock.dragging_window = window_id;
    ctx.focused_window_id = window_id;
}

/// undock ONE window from its current dock
/// leaf, restoring its pre-dock pos/size if a stash exists. Used
/// by the tab close button. Calls into `undockWindow`
/// which handles list removal + empty-leaf collapse; we just
/// handle the per-Window state cleanup that
/// `clearDockIdsInSubtree` does for the whole-subtree case.
/// No-op if the window id is unknown or not docked. Doesn't
/// mark the window "closed" in any user-visible sense - the
/// caller's next `u.window(...)` submission will see a
/// freshly-floated window at the restored pre-dock rect. If
/// the user wants click-to-close-permanently, they wire it
/// through their own `bool open` state and don't submit the
/// window when `open == false`.
fn undockSingleWindow(ctx: *UiContext, window_id: Id) void {
    const w: *Window = ctx.windows.get(window_id) orelse return;
    if (w.dock_node_id == null) {
        return;
    }

    // Restore pre-dock rect BEFORE the dock layer mutation so we
    // read the stash while it's still valid. Mirrors
    // `clearDockIdsInSubtree`'s ordering.
    if (w.pre_dock_pos) |pos| {
        w.pos = pos;
    }
    if (w.pre_dock_size) |size| {
        w.size = size;
    }
    w.pre_dock_pos = null;
    w.pre_dock_size = null;
    w.dock_node_id = null;

    _ = undockWindow(&ctx.dock, ctx.gpa, window_id);
}

///.iii: walk the dockspace subtree and overlay each leaf
/// with a "cross" of 5 drop zones - center, top, right, bottom,
/// left. Renders translucent rectangles into the current draw
/// list (caller sets it to foreground_dl). Hover highlight: the
/// zone containing the mouse gets a brighter fill. No actual
/// docking logic here - that's d.iv.
/// Zone layout per leaf (cross arrangement):
///     ┌─────────────────────────────┐
///     │ ┌──┐ │
///     │ │T │ │
///     │ └──┘ │
///     │ ┌──┐┌────┐┌──┐ │
///     │ │L ││ C ││R │ │
///     │ └──┘└────┘└──┘ │
///     │ ┌──┐ │
///     │ │B │ │
///     │ └──┘ │
///     └─────────────────────────────┘
/// `ZONE_SIZE_PX` controls the side length of the small edge zones
/// (T/R/B/L); the center zone is slightly larger so it's an
/// obvious "drop into tabs" target.
///.iv: the five drop-zone kinds. Maps to a
/// `DockRequest` variant when a release hits a zone:
///   .center → dock_as_tab (merge into target's tab list)
///   .top/.right/.bottom/.left → dock_as_split with matching Dir
const DockTargetKind = enum { center, top, right, bottom, left };

/// Result of `hitTestDockTargets`. Returned by-value (one of
/// these per release-frame, max), so no need to allocate.
const DockTargetHit = struct {
    target_node: Id,
    kind: DockTargetKind,
};

/// Compute the 5 zone rectangles for a leaf with `pos` + `size`.
/// Returns null when the leaf is too small to host the cross
/// without overlap. Shared by `renderDockTargetOverlay` and
/// `hitTestDockTargets` so the geometry can't drift between
/// what's drawn and what's hit.
/// Compute the 5 zone rectangles for a leaf with `pos` + `size`.
/// Returns null when the leaf is too small to host the cross
/// without overlap. Shared by `renderDockTargetOverlay` and
/// `hitTestDockTargets` so the geometry can't drift between
/// what's drawn and what's hit.
/// Adaptive sizing: mirrors imgui's approach in
/// `DockNodeCalcDropRectsAndTestMousePos` (imgui.cpp:19897-19903):
/// zone size scales with both font size AND leaf size so the
/// overlay feels right whether the leaf is 200px or 1500px tall,
/// and whether the UI is at 12pt or 24pt. Formula matches imgui:
/// `hs = min(font * 1.5, max(font * 0.5, parent_smaller_axis / 8))`.
/// Center zone is slightly larger (~10%) so dock-as-tab - the
/// most-common action - feels like the natural default target.
fn dockTargetZonesFor(
    pos: Vec2,
    size: Vec2,
    font_size: f32,
) ?[5]Rectangle {
    const min_dim: f32 = @min(size[0], size[1]);
    const hs: f32 = clamp(min_dim / 8.0, font_size * 0.5, font_size * 1.5);
    const ZONE_SIZE: f32 = @floor(hs * 2.0); // edge zones, side length
    const CENTER_SIZE: f32 = @floor(hs * 2.0 * 1.10); // center, ~10% bigger
    const GAP: f32 = @floor(hs * 0.20);
    const total_w: f32 = ZONE_SIZE * 2 + CENTER_SIZE + GAP * 2;
    // Skip layouts where the cross would overflow the leaf.
    if (min_dim < total_w + hs) {
        return null;
    }

    const cx: f32 = pos[0] + size[0] * 0.5;
    const cy: f32 = pos[1] + size[1] * 0.5;

    return .{
        // .center - index 0, dock-as-tab.
        .{ .x = cx - CENTER_SIZE * 0.5, .y = cy - CENTER_SIZE * 0.5, .width = CENTER_SIZE, .height = CENTER_SIZE },
        // .top - index 1.
        .{
            .x = cx - ZONE_SIZE * 0.5,
            .y = cy - CENTER_SIZE * 0.5 - GAP - ZONE_SIZE,
            .width = ZONE_SIZE,
            .height = ZONE_SIZE,
        },
        // .right - index 2.
        .{ .x = cx + CENTER_SIZE * 0.5 + GAP, .y = cy - ZONE_SIZE * 0.5, .width = ZONE_SIZE, .height = ZONE_SIZE },
        // .bottom - index 3.
        .{ .x = cx - ZONE_SIZE * 0.5, .y = cy + CENTER_SIZE * 0.5 + GAP, .width = ZONE_SIZE, .height = ZONE_SIZE },
        // .left - index 4.
        .{
            .x = cx - CENTER_SIZE * 0.5 - GAP - ZONE_SIZE,
            .y = cy - ZONE_SIZE * 0.5,
            .width = ZONE_SIZE,
            .height = ZONE_SIZE,
        },
    };
}

/// score how strongly a zone "pulls" the
/// cursor. Returns a value in [0, 1] where 1 = mouse is exactly
/// at the zone's center, falling to 0 at and beyond `pull_radius`.
/// Why scoring instead of imgui's two-radii hit-test? imgui's
/// `DockNodeCalcDropRectsAndTestMousePos` uses concentric
/// circles (r_threshold_center, r_threshold_sides) with magic
/// constants 1.4 / 2.6 - works, but has flicker zones at the
/// circle boundaries and gives no visual feedback for "you're
/// almost on a target". We instead score every zone, then:
/// (a) the highest-score zone is the hit (single deterministic
/// winner, no boundary flicker), and (b) visual opacity is
/// modulated by score so the user sees their cursor smoothly
/// "pull" toward whichever target they're closest to.
/// Pull radius is sized relative to the zone itself - bigger
/// zones (center) have a slightly larger pull, so dock-as-tab
/// stays the natural default when the mouse is in the rough
/// middle of the cross.
fn dockZoneScore(
    zone: Rectangle,
    mp: Vec2,
    pull_scale: f32,
) f32 {
    const zcx: f32 = zone.x + zone.width * 0.5;
    const zcy: f32 = zone.y + zone.height * 0.5;
    const dx: f32 = mp[0] - zcx;
    const dy: f32 = mp[1] - zcy;
    const dist: f32 = @sqrt(dx * dx + dy * dy);
    // Diagonal of the zone gives a natural pull radius - points at
    // the zone's corner score ~0.3, points outside the zone fall
    // off quickly but not abruptly. `pull_scale` (caller-tuned)
    // scales this; >1.0 widens the pull, <1.0 tightens it.
    const diag: f32 = @sqrt(zone.width * zone.width + zone.height * zone.height) * 0.5;
    const pull: f32 = diag * pull_scale;
    if (dist >= pull) {
        return 0;
    }
    return 1.0 - (dist / pull);
}

/// Score all five zones; returns array indexed parallel to
/// `dockTargetZonesFor`. Center gets a slightly wider pull
/// (1.15x) so it remains the default for cursor positions near
/// the leaf's geometric center - matches imgui's "center wins
/// from a wider radius" intuition without the magic constants.
fn dockZoneScores(zones: [5]Rectangle, mp: Vec2) [5]f32 {
    return .{
        dockZoneScore(zones[0], mp, 1.15), // center: wider pull
        dockZoneScore(zones[1], mp, 1.00), // top
        dockZoneScore(zones[2], mp, 1.00), // right
        dockZoneScore(zones[3], mp, 1.00), // bottom
        dockZoneScore(zones[4], mp, 1.00), // left
    };
}

/// The rect the dragged window will occupy if dropped on `kind` over this leaf —
/// drawn as the translucent landing preview.
fn dockLandingRect(node: *const DockNode, kind: DockTargetKind) Rectangle {
    const x: f32 = node.pos[0];
    const y: f32 = node.pos[1];
    const w: f32 = node.size[0];
    const h: f32 = node.size[1];
    return switch (kind) {
        .center => .{ .x = x, .y = y, .width = w, .height = h },
        .left => .{ .x = x, .y = y, .width = w * 0.5, .height = h },
        .right => .{ .x = x + w * 0.5, .y = y, .width = w * 0.5, .height = h },
        .top => .{ .x = x, .y = y, .width = w, .height = h * 0.5 },
        .bottom => .{ .x = x, .y = y + h * 0.5, .width = w, .height = h * 0.5 },
    };
}

fn hitTestDockTargetsInSubtree(
    ctx: *const UiContext,
    root_id: Id,
    mp: Vec2,
) ?DockTargetHit {
    const node: *DockNode = ctx.dock.lookup(root_id) orelse return null;
    if (node.split) |s| {
        if (hitTestDockTargetsInSubtree(ctx, s.child_ids[0], mp)) |hit| {
            return hit;
        }
        if (hitTestDockTargetsInSubtree(ctx, s.child_ids[1], mp)) |hit| {
            return hit;
        }
        return null;
    }
    if (node.leaf == null) {
        return null;
    }

    // no_docking_over_me skips the entire leaf - no
    // overlay, no hit. Belongs here so both center (tab merge)
    // and side splits are blocked uniformly.
    if (node.flags.no_docking_over_me) {
        return null;
    }

    const zones: [5]Rectangle = dockTargetZonesFor(node.pos, node.size, ctx.style.font_size) orelse return null;
    var scores: [5]f32 = dockZoneScores(zones, mp);

    // no_split zeros out the non-center zones. The
    // user can still merge a window in as a tab (center), but
    // can't split this leaf.
    if (node.flags.no_split) {
        scores[1] = 0;
        scores[2] = 0;
        scores[3] = 0;
        scores[4] = 0;
    }

    // Pick the zone with the highest score. Tie-break order
    // (center, top, right, bottom, left) is encoded by argmax
    // walking in array order - stable and deterministic.
    var best_idx: usize = 0;
    var best_score: f32 = scores[0];
    for (scores[1..], 1..) |s, i| {
        if (s > best_score) {
            best_score = s;
            best_idx = i;
        }
    }
    if (best_score <= 0) {
        return null; // not pulled by any square → drop floats, doesn't dock
    }

    const kinds = [_]DockTargetKind{ .center, .top, .right, .bottom, .left };
    return .{ .target_node = node.id, .kind = kinds[best_idx] };
}

fn hitTestDockTargets(
    ctx: *const UiContext,
    mp: Vec2,
) ?DockTargetHit {
    for (ctx.dock.dockspaces_this_frame.items) |dockspace_id| {
        if (hitTestDockTargetsInSubtree(ctx, dockspace_id, mp)) |hit| {
            return hit;
        }
    }
    return null;
}

fn renderDockTargetOverlay(ctx: *UiContext, root_id: Id) void {
    const node: *DockNode = ctx.dock.lookup(root_id) orelse return;
    if (node.split) |s| {
        renderDockTargetOverlay(ctx, s.child_ids[0]);
        renderDockTargetOverlay(ctx, s.child_ids[1]);
        return;
    }
    if (node.leaf == null) {
        return;
    }

    // no_docking_over_me - skip the whole overlay.
    // Matches `hitTestDockTargetsInSubtree` behavior so the
    // user sees no zones over leaves they can't dock into.
    if (node.flags.no_docking_over_me) {
        return;
    }

    const zones: [5]Rectangle = dockTargetZonesFor(node.pos, node.size, ctx.style.font_size) orelse return;
    const mp: Vec2 = ctx.input.mouse_pos;
    var scores: [5]f32 = dockZoneScores(zones, mp);

    // no_split zeros out non-center zones so they're
    // not rendered. Mirrors the hit-test gate above.
    if (node.flags.no_split) {
        scores[1] = 0;
        scores[2] = 0;
        scores[3] = 0;
        scores[4] = 0;
    }

    // Find the winner so we can outline it more strongly.
    var best_idx: usize = 0;
    var best_score: f32 = scores[0];
    for (scores[1..], 1..) |s, i| {
        if (s > best_score) {
            best_score = s;
            best_idx = i;
        }
    }

    // Landing preview ONLY when the cursor is on a square — dropping off the cross
    // leaves the window floating, so we don't preview a dock there.
    if (best_score > 0) {
        const preview_kinds = [_]DockTargetKind{ .center, .top, .right, .bottom, .left };
        const preview: Rectangle = dockLandingRect(node, preview_kinds[best_idx]);
        drawRectFilled(ctx, preview, Color.toWire(.{ .r = 80, .g = 130, .b = 220, .a = 70 }));
    }

    for (zones, scores, 0..) |z, score, i| {
        // skip drawing entirely for side zones on
        // no_split leaves. Score is already zero (above) - this
        // also drops the BASE_ALPHA pass so the leaf shows a
        // single tab-merge target, not a cross with greyed sides.
        if (node.flags.no_split and i != 0) {
            continue;
        }

        // opacity modulated by score. Even
        // zones with score 0 stay faintly visible (BASE_ALPHA)
        // so the cross silhouette is always readable. Zones the
        // cursor is pulling toward boost their alpha proportional
        // to score² - quadratic to make the snap feel decisive
        // without losing the "I can see the runners-up" feedback.
        const BASE_ALPHA: f32 = 80.0;
        const PEAK_ALPHA: f32 = 230.0;
        const alpha: f32 = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * score * score;

        // Color: subtle blue for low-score, focus-blue for the
        // winner. Lerp the channels too so the transition reads.
        const r: u8 = @round(80.0 + 20.0 * score);
        const g: u8 = @round(120.0 + 40.0 * score);
        const b: u8 = @round(200.0 + 40.0 * score);
        drawRectFilled(ctx, z, Color.toWire(.{ .r = r, .g = g, .b = b, .a = @trunc(alpha) }));

        // Outline: stronger for the winner, faint for others.
        const outline_alpha: u8 = if (i == best_idx and best_score > 0) 255 else 180;
        drawRectLines(ctx, z, .{ .r = 200, .g = 220, .b = 255, .a = outline_alpha });
    }
}

fn renderDockLeafTabBars(ctx: *UiContext, root_id: Id) void {
    const node: *DockNode = ctx.dock.lookup(root_id) orelse return;
    // Recurse into splits first (the parent is just a container);
    // leaves are where the rendering happens.
    if (node.split) |s| {
        renderDockLeafTabBars(ctx, s.child_ids[0]);
        renderDockLeafTabBars(ctx, s.child_ids[1]);
        return;
    }
    // Leaf.
    if (node.leaf == null) {
        return;
    }
    const leaf: *DockNode.LeafData = &node.leaf.?;

    // `no_tab_bar` and `hidden_tab_bar` both suppress
    // the tab strip. They differ only in semantics (no_tab_bar
    // is "permanent, no toggle"; hidden_tab_bar is "currently
    // hidden, future UX adds a tiny indicator to toggle back");
    // rendering treats both identically. No bar = the leaf's
    // selected window's content fills the full leaf rect.
    if (node.flags.no_tab_bar or node.flags.hidden_tab_bar) {
        return;
    }

    // Empty leaves get a faint "drop windows here" hint - keeps
    // the layout visible during early demo iteration. Reuses the
    // dockspace's outline color so we don't introduce new style
    // tokens for this minimal scaffolding.
    if (leaf.window_ids.items.len == 0) {
        drawRect(
            ctx,
            .{ .x = node.pos[0], .y = node.pos[1], .width = node.size[0], .height = node.size[1] },
            Color.toWire(.{ .r = 30, .g = 40, .b = 60, .a = 80 }),
        );
        return;
    }

    const tab_h: f32 = ctx.style.title_bar_height;
    const bar_rect: Rectangle = .{
        .x = node.pos[0],
        .y = node.pos[1],
        .width = node.size[0],
        .height = tab_h,
    };

    // Tab strip background - slightly darker than the window
    // chrome to visually distinguish docked leaves from floating
    // windows. Same idea as imgui's tab bar coloring.
    drawRectFilled(ctx, bar_rect, Color.toWire(.{
        .r = 36,
        .g = 41,
        .b = 50,
        .a = 255,
    }));

    // Layout tabs across the bar. Even split per tab for the
    // minimal implementation; 5.5j-reorder will switch to per-tab
    // width sized by label.
    const n: f32 = float(leaf.window_ids.items.len);
    const tab_w: f32 = node.size[0] / n;
    const mp: Vec2 = ctx.input.mouse_pos;
    const click: bool = ctx.input.mouse_left_clicked;

    // tab close button geometry. Reserved
    // when the leaf doesn't have `no_close_button` set. Sized
    // proportional to the tab bar height so it scales with
    // `style.font_size`. Per-tab; pad the label so text doesn't
    // overlap the ×.
    const has_close: bool = !node.flags.no_close_button;
    const close_size: f32 = @min(tab_h * 0.55, ctx.style.font_size + 4);
    const close_pad: f32 = (tab_h - close_size) * 0.5;

    // Deferred mutations. Mutating `leaf.window_ids` during
    // the iteration below would invalidate slices. At most
    // one of each per frame (single mouse click), so a single
    // slot apiece is enough.
    var pending_close: ?Id = null;
    var pending_detach: ?Id = null;
    var pending_reorder: ?struct { wid: Id, target_idx: usize } = null;

    // Strip-aware drag classification.
    // While the user owns a `tab_drag_id`:
    //   - Mouse Y inside the strip's vertical range (with a
    //     small ±4 px slack) → REORDER mode. Walk to the tab
    //     whose horizontal slot the cursor is over; if it's
    //     different from the dragged tab's current index,
    //     queue a reorder.
    //   - Mouse Y outside the strip by more than
    //     DETACH_Y_THRESHOLD → DETACH mode. The window tears
    //     out into a floating drag (5.5j turn-332 flow).
    //   - Between strip edge and DETACH_Y_THRESHOLD: dead
    //     zone, no action. Lets the user hover ambiguously
    //     without committing.
    // Earlier the threshold was an omnidirectional
    // 24-px circle. That counted purely horizontal motion
    // within the strip as a detach - wrong: horizontal motion
    // IS reorder. Splitting the cases by axis matches imgui's
    // behavior and gives the user predictable affordance.
    const REORDER_Y_SLACK: f32 = 4;
    const DETACH_Y_THRESHOLD: f32 = 16;

    for (leaf.window_ids.items, 0..) |wid, i| {
        const i_f: f32 = float(i);
        const tab_rect: Rectangle = .{
            .x = node.pos[0] + i_f * tab_w,
            .y = node.pos[1],
            .width = tab_w,
            .height = tab_h,
        };

        const is_active: bool = wid == leaf.selected_window_id;
        const is_hover: bool =
            mp[0] >= tab_rect.x and mp[0] < tab_rect.x + tab_rect.width and
            mp[1] >= tab_rect.y and mp[1] < tab_rect.y + tab_rect.height;

        // Active tab gets a brighter background, matching the
        // window-content area's hue. Hovered-but-inactive tabs
        // get a subtle highlight to make clickability obvious.
        const fill: Color = if (is_active)
            .{ .r = 50, .g = 60, .b = 80, .a = 255 }
        else if (is_hover)
            .{ .r = 44, .g = 50, .b = 64, .a = 255 }
        else
            .{ .r = 36, .g = 41, .b = 50, .a = 255 };
        drawRectFilled(ctx, tab_rect, Color.toWire(fill));

        // Right-edge divider between tabs. Skip the last one to
        // avoid a vertical line against the leaf border.
        if (i + 1 < leaf.window_ids.items.len) {
            drawRectFilled(
                ctx,
                .{
                    .x = tab_rect.x + tab_rect.width - 1,
                    .y = tab_rect.y + 2,
                    .width = 1,
                    .height = tab_rect.height - 4,
                },
                Color.toWire(.{ .r = 80, .g = 90, .b = 110, .a = 200 }),
            );
        }

        // Close button rect (reserved at the right edge of the
        // tab, INSIDE the divider so it doesn't visually bleed
        // into the next tab). Suppressed entirely when the
        // node has the `no_close_button` flag.
        const close_rect: Rectangle = .{
            .x = tab_rect.x + tab_rect.width - close_size - close_pad,
            .y = tab_rect.y + close_pad,
            .width = close_size,
            .height = close_size,
        };
        const close_hover: bool = has_close and
            mp[0] >= close_rect.x and mp[0] < close_rect.x + close_rect.width and
            mp[1] >= close_rect.y and mp[1] < close_rect.y + close_rect.height;

        // Label. The close button occupies the rightmost
        // `close_size + close_pad` of the tab; if a label is
        // long enough to bleed under the × it'll overlap
        // visually but the close button still gets the click
        // (its hit-test is earlier in this loop).
        const label_color: Color = if (is_active)
            .{ .r = 235, .g = 240, .b = 250, .a = 255 }
        else
            .{ .r = 150, .g = 160, .b = 180, .a = 255 };
        const label: []const u8 =
            if (ctx.windows.get(wid)) |win| win.name() else "?";
        // Center-ish: small left pad, font ascent down from top.
        const text_pos: Vec2 = .{
            tab_rect.x + 8,
            tab_rect.y + (tab_h - ctx.style.font_size) * 0.5,
        };
        drawTextAtS(ctx, text_pos, label, Color.toWire(label_color));

        // Draw the close × glyph: two crossing line segments.
        // Hovered = red background + white lines; idle = no
        // background + dim lines. Background tint makes the
        // affordance obvious without needing a permanent border.
        if (has_close) {
            if (close_hover) {
                drawRectFilled(ctx, close_rect, Color.toWire(.{ .r = 200, .g = 60, .b = 60, .a = 220 }));
            }
            const inset: f32 = 3;
            const x_color: Color = if (close_hover)
                .{ .r = 240, .g = 240, .b = 240, .a = 255 }
            else if (is_active)
                .{ .r = 180, .g = 190, .b = 200, .a = 230 }
            else
                .{ .r = 120, .g = 130, .b = 145, .a = 200 };
            const thickness: f32 = if (close_hover) 2.0 else 1.5;
            const cl: *DrawList = ctx.current_draw_list.?;
            cl.addLine(
                ctx.frame_arena.allocator(),
                .{ close_rect.x + inset, close_rect.y + inset },
                .{ close_rect.x + close_rect.width - inset, close_rect.y + close_rect.height - inset },
                Color.toWire(x_color),
                thickness,
            );
            cl.addLine(
                ctx.frame_arena.allocator(),
                .{ close_rect.x + close_rect.width - inset, close_rect.y + inset },
                .{ close_rect.x + inset, close_rect.y + close_rect.height - inset },
                Color.toWire(x_color),
                thickness,
            );
        }

        // Click handling. Close button claims the click first;
        // if it didn't consume, this is a label-area press.
        // Press claims `active_id` so we can track motion across
        // frames and detach if the user drags past the threshold.
        // The tab selects immediately on press (imgui-matching).
        const tab_drag_id: Id = hashStr(wid, "__tab_drag");
        if (click and close_hover) {
            pending_close = wid;
        } else if (click and is_hover and ctx.active_id == 0) {
            // Press on tab label → claim, stash press position,
            // and select this tab right away.
            ctx.active_id = tab_drag_id;
            ctx.active_id_press_screen = mp;
            leaf.selected_window_id = wid;
            ctx.dock.dragging_window = null; // drop stale drag state
        }

        // While we own the id: classify the drag as REORDER
        // (mouse still in strip) or DETACH (mouse left strip
        // by more than DETACH_Y_THRESHOLD). Same mouse_left
        // gating as before - including the release frame so a
        // drag-and-release inside one tick still classifies.
        if (ctx.active_id == tab_drag_id) {
            const strip_y_lo: f32 = bar_rect.y;
            const strip_y_hi: f32 = bar_rect.y + tab_h;
            const in_strip: bool =
                mp[1] >= strip_y_lo - REORDER_Y_SLACK and
                mp[1] < strip_y_hi + REORDER_Y_SLACK;
            const above_strip: bool = mp[1] < strip_y_lo - DETACH_Y_THRESHOLD;
            const below_strip: bool = mp[1] >= strip_y_hi + DETACH_Y_THRESHOLD;

            if (above_strip or below_strip) {
                // Mouse pulled clear of the strip - detach.
                pending_detach = wid;
            } else if (in_strip) {
                // Reorder: find which tab slot the cursor
                // falls in and queue a move if different from
                // current index. The relative_x can go
                // negative (cursor left of the strip's left
                // edge but still in Y range) - clamp to 0.
                const relative_x: f32 = mp[0] - node.pos[0];
                const slot_raw: f32 = @max(0, relative_x / tab_w);
                const slot_max: usize = leaf.window_ids.items.len - 1;
                const target_idx: usize = @min(
                    floori(usize, slot_raw),
                    slot_max,
                );
                if (target_idx != i) {
                    pending_reorder = .{ .wid = wid, .target_idx = target_idx };
                }
            }
            // Between strip edge and DETACH_Y_THRESHOLD →
            // dead zone, no action this frame.

            if (ctx.input.mouse_left_released) {
                ctx.active_id = 0;
            }
        }
    }

    // Drain deferred mutations after the iteration. Order
    // matters: close THEN detach THEN reorder. Close + detach
    // both remove a window from the leaf; reorder is a swap
    // within the leaf. In practice these are mutually
    // exclusive (only one fires per frame), but if both
    // somehow trigger, mutations earlier in the list take
    // priority.
    if (pending_close) |wid| {
        undockSingleWindow(ctx, wid);
    }
    if (pending_detach) |wid| {
        detachAndStartDrag(ctx, wid);
    }
    if (pending_reorder) |r| {
        if (leaf.indexOf(r.wid)) |cur_idx| {
            _ = leaf.window_ids.orderedRemove(cur_idx);
            leaf.window_ids.insert(ctx.gpa, r.target_idx, r.wid) catch {};
            node.generation +%= 1;
        }
    }
}

// ============================================================================
// Layout - cursor advancement.
// ============================================================================

// ============================================================================
// Widget: button
// ============================================================================

fn buttonImpl(
    ctx: *UiContext,
    label: []const u8,
    opts: ButtonOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const u: Ui = .{ .ctx = ctx };
    const id: Id = widgetId(ctx, w, label);
    const item_flags: ItemFlags = currentItemFlagsImpl(ctx);

    // Auto-fit to label unless caller passed an explicit size.
    const text_size: Vec2 = measureTextS(ctx, label);
    const padding: Vec2 = ctx.style.frame_padding;
    const size: Vec2 = .{
        if (opts.size[0] > 0) opts.size[0] else text_size[0] + 2 * padding[0],
        if (opts.size[1] > 0) opts.size[1] else text_size[1] + 2 * padding[1],
    };

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = size[0], .height = size[1] };

    // itemAdd handles the clip test + hovered_id update. If the
    // widget is fully off-screen we still need to advance the
    // layout cursor so siblings flow correctly.
    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{ .repeat = item_flags.button_repeat });

    const bg: Color = if (r.held)
        ctx.style.button_active
    else if (r.hovered)
        ctx.style.button_hovered
    else
        ctx.style.button;

    drawRectFilled(ctx, rect, Color.toWire(bg));
    const label_pos: Vec2 = .{ rect.x + padding[0], rect.y + padding[1] };
    drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = r.hovered;
    markItemPost(ctx, w, false);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return r.pressed;
}

/// Compact button. Same hit-test + state machine as `buttonImpl`,
/// but the vertical frame_padding is zero so the button is exactly
/// `font_size` tall - fits inline with `text` cleanly. Horizontal
/// padding is preserved so labels don't crowd the rect edges.
fn smallButtonImpl(ctx: *UiContext, label: []const u8) bool {
    const w: *Window = ctx.current_window orelse return false;
    const u: Ui = .{ .ctx = ctx };
    const id: Id = widgetId(ctx, w, label);

    const text_size: Vec2 = measureTextS(ctx, label);
    const padding: Vec2 = ctx.style.frame_padding;
    const size: Vec2 = .{ text_size[0] + 2 * padding[0], ctx.style.font_size };
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = size[0], .height = size[1] };

    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});

    const bg: Color = if (r.held)
        ctx.style.button_active
    else if (r.hovered)
        ctx.style.button_hovered
    else
        ctx.style.button;
    drawRectFilled(ctx, rect, Color.toWire(bg));
    drawTextAtS(ctx, .{ rect.x + padding[0], rect.y }, label, Color.toWire(ctx.style.text));

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = r.hovered;
    markItemPost(ctx, w, false);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, size, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return r.pressed;
}

/// Square button with a triangle glyph pointing in `dir`. Standard
/// state machine; the only thing special is the rendering - a
/// filled triangle inside the rect, inset by `padding` on all sides.
fn arrowButtonImpl(
    ctx: *UiContext,
    str_id: []const u8,
    dir: ArrowDir,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const u: Ui = .{ .ctx = ctx };
    const id: Id = widgetId(ctx, w, str_id);

    const padding: Vec2 = ctx.style.frame_padding;
    const side: f32 = ctx.style.font_size + 2 * padding[1];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = side, .height = side };

    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, .{ side, side }, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});

    const bg: Color = if (r.held)
        ctx.style.button_active
    else if (r.hovered)
        ctx.style.button_hovered
    else
        ctx.style.button;
    drawRectFilled(ctx, rect, Color.toWire(bg));

    // Triangle inset by ~30% of side on each axis.
    const inset: f32 = side * 0.30;
    const cx: f32 = rect.x + side * 0.5;
    const cy: f32 = rect.y + side * 0.5;
    const x_lo: f32 = rect.x + inset;
    const x_hi: f32 = rect.x + side - inset;
    const y_lo: f32 = rect.y + inset;
    const y_hi: f32 = rect.y + side - inset;
    const tri: struct { a: Vec2, b: Vec2, c: Vec2 } = switch (dir) {
        .right => .{ .a = .{ x_lo, y_lo }, .b = .{ x_hi, cy }, .c = .{ x_lo, y_hi } },
        .left => .{ .a = .{ x_hi, y_lo }, .b = .{ x_lo, cy }, .c = .{ x_hi, y_hi } },
        .up => .{ .a = .{ x_lo, y_hi }, .b = .{ cx, y_lo }, .c = .{ x_hi, y_hi } },
        .down => .{ .a = .{ x_lo, y_lo }, .b = .{ x_hi, y_lo }, .c = .{ cx, y_hi } },
    };
    w.draw_list.addTriangleFilled(ctx.frame_arena.allocator(), tri.a, tri.b, tri.c, Color.toWire(ctx.style.text));

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = r.hovered;
    markItemPost(ctx, w, false);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, .{ side, side }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return r.pressed;
}

// ============================================================================
// text helpers + Value + invisibleButton impls
// ============================================================================
// Imgui parity references (read alongside these for context):
//   - SeparatorTextEx in imgui_widgets.cpp:1735
//   - InvisibleButton in imgui_widgets.cpp:844
//   - TextLink in imgui_widgets.cpp:1517
//   - Value (4 overloads) in imgui_widgets.cpp:9059+
// Design notes are in `src/notes/imgui-plan.md` step 1.1 + the public
// doc comments on `Ui.separatorText` / `Ui.invisibleButton` /
// `Ui.textLink` / `Ui.textLinkOpenURL` / `Ui.value`.

fn textLinkImpl(ctx: *UiContext, label: []const u8) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const sz: Vec2 = measureTextS(ctx, label);
    const rect: Rectangle = .{ .x = at[0], .y = at[1], .width = sz[0], .height = sz[1] };

    // Hit test + state machine (mirrors buttonImpl).
    const hovered: bool = pointInRect(ctx.input.mouse_pos, rect);
    if (hovered) {
        ctx.hovered_id = id;
    }

    var pressed: bool = false;
    if (hovered and ctx.input.mouse_left_clicked) {
        ctx.active_id = id;
        ctx.active_id_just_activated = true;
    }
    if (ctx.active_id == id and ctx.input.mouse_left_released) {
        if (hovered) {
            pressed = true;
        }
        ctx.active_id = 0;
    }
    if (triggerNavActivate(ctx, id)) {
        pressed = true;
    }

    // Cursor hint: pointing-hand on hover. Matches imgui. Defensive
    // `input_state` is null for the host-test harness where there's no
    // browser cursor to set.
    if (hovered) {
        if (ctx.input_state) |s| {
            runtime.input.setMouseCursor(s, .pointing_hand);
        }
    }

    // Pick the text colour from the active state. `held` (active_id
    // matches AND we got here, meaning we're inside ButtonBehavior's
    // press window) reads as hovered too - keeps the visual "I'm
    // interacting with you" on through the press-and-hold gesture.
    const held: bool = (ctx.active_id == id);
    const text_color: Color = if (hovered or held)
        ctx.style.text_link_hovered
    else
        ctx.style.text_link;
    drawTextAtS(ctx, at, label, Color.toWire(text_color));

    // Underline - 1px rect just below the text rect. imgui places this
    // a fifth-of-a-descent below the baseline using font metrics we
    // don't expose yet (`g.FontBaked->Descent`); placing one pixel
    // below the text rect is the close-enough version that doesn't
    // need the descent plumbing. Looks right at 10-16px font sizes.
    drawRectFilled(ctx, .{
        .x = at[0],
        .y = at[1] + sz[1] + 1,
        .width = sz[0],
        .height = 1,
    }, Color.toWire(ctx.style.text_link_underline));

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, false);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, sz, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return pressed;
}

/// Lifted from `valueImpl` so it can be unit-tested without dragging a
/// full `UiContext` into the test harness. Pure: writes the formatted
/// representation of `v` into `buf` and returns the slice. Dispatch
/// happens at comptime on `@TypeOf(v)`.
/// Returns `buf[0..0]` (empty) when bufPrint overflows - the caller's
/// widget then renders an empty value column, which is preferable to
/// crashing on a too-long value.
fn formatValue(buf: []u8, v: anytype) []const u8 {
    const T = @TypeOf(v);
    return switch (@typeInfo(T)) {
        .bool => if (v) "true" else "false",
        .int, .comptime_int => bufPrint(buf, "{d}", .{v}) catch buf[0..0],
        .float, .comptime_float => bufPrint(buf, "{d:.3}", .{v}) catch buf[0..0],
        .@"enum" => @tagName(v),
        .pointer => |p| blk: {
            if (p.size == .slice and p.child == u8) {
                // String slice - passthrough. `v` is `[]const u8` or `[]u8`;
                // both coerce to `[]const u8` for the return.
                break :blk @as([]const u8, v);
            } else {
                // Other pointer kinds (single-item, many-item) - the {any}
                // fallback prints a hex address. Useful for debugging but
                // not pretty; callers wanting nicer formatting reach for
                // `Ui.text` directly.
                break :blk bufPrint(buf, "{any}", .{v}) catch buf[0..0];
            }
        },
        else => bufPrint(buf, "{any}", .{v}) catch buf[0..0],
    };
}

fn valueImpl(
    ctx: *UiContext,
    label: []const u8,
    v: anytype,
) void {
    const w: *Window = ctx.current_window orelse return;

    // Format the value into a stack buffer, then combine with the label
    // into one render-able string. 256-byte combined cap matches the
    // sibling `textImpl` budget; longer values get truncated cleanly.
    var value_buf: [128]u8 = undefined;
    const value_str: []const u8 = formatValue(&value_buf, v);

    var combined: [256]u8 = undefined;
    const formatted: []u8 = bufPrint(&combined, "{s}: {s}", .{ label, value_str }) catch combined[0..0];

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const sz: Vec2 = measureTextS(ctx, formatted);
    drawTextAtS(ctx, at, formatted, Color.toWire(ctx.style.text));

    w.layout.last_item_id = 0; // Read-only display - no ID, not interactive.
    w.layout.last_item_rect = .{ .x = at[0], .y = at[1], .width = sz[0], .height = sz[1] };
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, w.layout.last_item_rect);

    advanceLayout(w, sz, ctx.style.item_spacing, ctx.style.window_padding[0]);
}

// ============================================================================
// pushStyle / popStyle - reflection-based style overrides
// ============================================================================
// Mechanically simple: at push, copy the named field's current
// bytes into a snapshot, then write the new value. At pop, copy
// the bytes back. Works for any POD field type - and every
// current `Style` field is POD (Color, Vec2, f32, i32,
// `?*const Font`). If a non-POD field is ever added to Style,
// `pushStyle` will keep compiling but won't properly restore;
// future maintainer: add an `is_pod` guard at that point.

// ============================================================================
// Widget: checkbox
// ============================================================================

fn checkboxImpl(
    ctx: *UiContext,
    label: []const u8,
    value: *bool,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    // Layout: square box on the left, label to the right, sized for
    // the row. The clickable rect spans both - clicking the label
    // toggles too, ImGui-style.
    const box_size: f32 = ctx.style.font_size + 4;
    const padding: Vec2 = ctx.style.frame_padding;
    const text_size: Vec2 = measureTextS(ctx, label);
    const total_w: f32 = box_size + ctx.style.item_spacing[0] + text_size[0];
    const total_h: f32 = box_size;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect = Rectangle{ .x = at[0], .y = at[1], .width = total_w, .height = total_h };
    const box_rect = Rectangle{ .x = at[0], .y = at[1], .width = box_size, .height = box_size };

    const u: Ui = .{ .ctx = ctx };
    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, .{ total_w, total_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    var changed: bool = false;
    if (r.pressed) {
        value.* = !value.*;
        changed = true;
    }

    // Frame-bg variations match ImGui's checkbox styling.
    const bg: Color = if (r.held)
        ctx.style.frame_bg_active
    else if (r.hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, box_rect, Color.toWire(bg));

    // Inner tick when checked. Solid square inset by `inset` —
    // reads correctly at this size without a real check shape.
    if (value.*) {
        const inset: f32 = 4;
        const tick = Rectangle{
            .x = box_rect.x + inset,
            .y = box_rect.y + inset,
            .width = box_size - 2 * inset,
            .height = box_size - 2 * inset,
        };
        drawRectFilled(ctx, tick, Color.toWire(ctx.style.text));
    }

    // Label to the right of the box, vertically centered against
    // the box.
    const label_pos: Vec2 = .{
        box_rect.x + box_size + ctx.style.item_spacing[0],
        at[1] + (box_size - ctx.style.font_size) / 2 + padding[1] - padding[1],
    };
    drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = r.hovered;
    markItemPost(ctx, w, changed);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, .{ total_w, total_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return changed;
}

// ============================================================================
// Widget: slider - comptime dispatch on numeric pointer types
// ============================================================================

/// Multi-component slider: N narrow bars side-by-side, each editing
/// `arr_ptr[i]`. Total row width is split into N equal bars with
/// `style.item_inner_spacing` between them; the label appears AFTER
/// the last bar. Each sub-bar gets a synthetic ID
/// `combineId(parent_id, i)` so the active-state machine tracks them
/// independently - dragging on bar 0 doesn't also drive bar 1.
/// Returns true if ANY component changed this frame.
fn sliderArray(
    ctx: *UiContext,
    label: []const u8,
    arr_ptr: anytype,
    comptime T: type,
    comptime n: usize,
    opts: SliderOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const parent_id: Id = widgetId(ctx, w, label);

    // Compute per-bar width from the row's available space minus the
    // label reserve and (N-1) inter-bar gaps. Floor each bar at 30px
    // so multi-component widgets stay usable in narrow windows.
    // Inner spacing between sub-bars is tighter than item_spacing
    // (sub-bars belong to the same widget) - fixed 4px for now.
    const padding: Vec2 = ctx.style.frame_padding;
    const inner_spacing: f32 = 4;
    const label_size: Vec2 = measureTextS(ctx, label);
    const inner_w: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0] - resolveCursor(
        w,
        ctx.style.item_spacing[0],
    )[0];
    const label_reserve: f32 = label_size[0] + ctx.style.item_spacing[0];
    const gaps: f32 = inner_spacing * float(n - 1);
    const default_total_w: f32 = inner_w - label_reserve - gaps;
    var total_w: f32 = effectiveItemWidth(ctx, opts.width, default_total_w);
    if (total_w < 60 * float(n)) {
        total_w = 60 * float(n);
    }
    const bar_w: f32 = total_w / float(n);
    const bar_h: f32 = ctx.style.font_size + 2 * padding[1];

    var changed: bool = false;
    var any_hovered: bool = false;
    const start: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    inline for (0..n) |i| {
        const id: Id = hashInt(parent_id, @intCast(i));
        const x: f32 = start[0] + (bar_w + inner_spacing) * float(i);
        const bar: Rectangle = .{ .x = x, .y = start[1], .width = bar_w, .height = bar_h };

        const hovered: bool = pointInRect(ctx.input.mouse_pos, bar);
        if (hovered) {
            ctx.hovered_id = id;
            any_hovered = true;
        }
        if (hovered and ctx.input.mouse_left_clicked) {
            ctx.active_id = id;
            ctx.active_id_just_activated = true;
        }
        if (ctx.active_id == id and !ctx.input.mouse_left_down) {
            ctx.active_id = 0;
        }
        if (ctx.active_id == id) {
            const rel: f32 = clamp((ctx.input.mouse_pos[0] - bar.x) / bar.width, 0, 1);
            const new_v_f64: f64 = opts.min + @as(f64, rel) * (opts.max - opts.min);
            const new_v: T = scalarFromF64(T, new_v_f64);
            if (new_v != arr_ptr[i]) {
                arr_ptr[i] = new_v;
                changed = true;
            }
        }

        // Draw bar background + filled grab portion.
        const held: bool = ctx.active_id == id;
        const bg: ColorU32 = Color.toWire(ctx.style.frame_bg);
        const fill: ColorU32 = Color.toWire(if (held) ctx.style.button_active else ctx.style.button);
        drawRectFilled(ctx, bar, bg);
        const v_f64: f64 = scalarToF64(T, arr_ptr[i]);
        const rel_cur: f32 = if (opts.max > opts.min)
            clamp(@as(f32, @floatCast((v_f64 - opts.min) / (opts.max - opts.min))), 0, 1)
        else
            0;
        const grab_w: f32 = @max(4, rel_cur * bar.width);
        drawRectFilled(
            ctx,
            .{ .x = bar.x, .y = bar.y, .width = grab_w, .height = bar.height },
            fill,
        );

        // Per-bar value text centered inside.
        var val_buf: [32]u8 = undefined;
        const val_str = formatScalar(&val_buf, T, arr_ptr[i], opts.fmt);
        const val_size: Vec2 = measureTextS(ctx, val_str);
        const text_pos: Vec2 = .{ bar.x + (bar.width - val_size[0]) * 0.5, bar.y + padding[1] };
        drawTextAtS(ctx, text_pos, val_str, Color.toWire(ctx.style.text));
    }

    // Label after the last bar.
    const total_consumed_w: f32 = total_w + gaps;
    const label_pos: Vec2 = .{ start[0] + total_consumed_w + ctx.style.item_spacing[0], start[1] + padding[1] };
    drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));

    // Bookkeeping: report the FULL row as the "item" so IsItemHovered
    // covers the whole multi-component widget.
    w.layout.last_item_id = parent_id;
    w.layout.last_item_rect = .{
        .x = start[0],
        .y = start[1],
        .width = total_consumed_w + label_reserve,
        .height = bar_h,
    };
    w.layout.last_item_hovered = any_hovered;
    markItemPost(ctx, w, changed);
    markItemNavigable(ctx, w, parent_id);

    advanceLayout(
        w,
        .{ total_consumed_w + label_reserve, bar_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return changed;
}

// ============================================================================
// Widget: colorEdit - comptime dispatch on RGB / RGBA representations
// ============================================================================

fn colorEditNFloat(
    ctx: *UiContext,
    label: []const u8,
    value: anytype,
    comptime N: usize,
    opts: ColorEditOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;

    // Use the label as the parent ID; each channel slider gets a
    // unique sub-ID via pushIdInt + the user-visible "" label. The
    // ID is hashed at the parent's id (window) - pushing it directly
    // without re-hashing keeps sub-controls under a single parent.
    ctx.id_stack.append(widgetId(ctx, w, label)) catch return false;
    defer _ = ctx.id_stack.pop();

    const padding: Vec2 = ctx.style.frame_padding;
    const bar_h: f32 = ctx.style.font_size + 2 * padding[1];
    const swatch_size: f32 = bar_h;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    // Compute available width per channel slider. Reserve room for
    // swatch on the right and the label after it.
    const label_size: Vec2 = measureTextS(ctx, label);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const reserve_right: f32 = swatch_size + ctx.style.item_spacing[0] + label_size[0];
    const total_bar_w: f32 = inner_right - at[0] - reserve_right;
    const channel_gap: f32 = 4;
    const slider_w = (total_bar_w - channel_gap * (float(N - 1))) / float(N);

    var any_changed: bool = false;
    var x: f32 = at[0];
    inline for (0..N) |i| {
        const ch_id: Id = hashInt(ctx.id_stack.top() orelse w.id, @intCast(i));
        const ch_rect = Rectangle{ .x = x, .y = at[1], .width = slider_w, .height = bar_h };
        const ch_hovered: bool = pointInRect(ctx.input.mouse_pos, ch_rect);
        if (ch_hovered) {
            ctx.hovered_id = ch_id;
        }
        if (ch_hovered and ctx.input.mouse_left_clicked) {
            ctx.active_id = ch_id;
        }
        if (ctx.active_id == ch_id and !ctx.input.mouse_left_down) {
            ctx.active_id = 0;
        }
        if (ctx.active_id == ch_id) {
            const t: f32 = clamp((ctx.input.mouse_pos[0] - ch_rect.x) / ch_rect.width, 0.0, 1.0);
            value[i] = t;
            any_changed = true;
        }

        // Draw channel background.
        const bg: Color = if (ctx.active_id == ch_id)
            ctx.style.frame_bg_active
        else if (ch_hovered)
            ctx.style.frame_bg_hovered
        else
            ctx.style.frame_bg;
        drawRectFilled(ctx, ch_rect, Color.toWire(bg));

        // Draw fill: tint by axis (R, G, B, A) so users can tell channels apart.
        const ch_color: Color = switch (i) {
            0 => .{ .r = 220, .g = 60, .b = 60, .a = 255 },
            1 => .{ .r = 60, .g = 220, .b = 60, .a = 255 },
            2 => .{ .r = 60, .g = 60, .b = 220, .a = 255 },
            else => .{ .r = 200, .g = 200, .b = 200, .a = 255 },
        };
        const v: f32 = clamp(value[i], 0.0, 1.0);
        const fill = Rectangle{
            .x = ch_rect.x,
            .y = ch_rect.y,
            .width = ch_rect.width * v,
            .height = ch_rect.height,
        };
        drawRectFilled(ctx, fill, Color.toWire(ch_color));

        // Overlay per-channel value text per `display_format`.
        // `.hex` uses a different display path (single combined
        // string on the swatch — handled below the loop), so we
        // skip per-channel text in that mode.
        if (opts.display_format != .hex) {
            var val_buf: [16]u8 = undefined;
            const val_text: []const u8 = switch (opts.display_format) {
                .float => bufPrint(&val_buf, "{d:.2}", .{v}) catch "",
                .int_0_255 => bufPrint(&val_buf, "{d}", .{colorChannelToInt255(v)}) catch "",
                .hex => unreachable,
            };
            if (val_text.len > 0) {
                const text_size: Vec2 = measureTextS(ctx, val_text);
                const tx: f32 = ch_rect.x + (ch_rect.width - text_size[0]) / 2;
                const ty: f32 = ch_rect.y + (ch_rect.height - ctx.style.font_size) / 2;
                drawTextAtS(ctx, .{ tx, ty }, val_text, Color.toWire(ctx.style.text));
            }
        }

        x += slider_w + channel_gap;
    }

    // Draw the swatch on the right showing the combined color.
    const swatch_x: f32 = inner_right - reserve_right + ctx.style.item_spacing[0];
    const swatch_rect = Rectangle{ .x = swatch_x, .y = at[1], .width = swatch_size, .height = swatch_size };
    const sw_color = Color{
        .r = colorChannelToInt255(value[0]),
        .g = colorChannelToInt255(value[1]),
        .b = colorChannelToInt255(value[2]),
        .a = if (N >= 4) colorChannelToInt255(value[3]) else @as(u8, 255),
    };
    drawRectFilled(ctx, swatch_rect, Color.toWire(sw_color));

    // `.hex` mode: render the combined hex string BELOW the row so
    // it doesn't overlap the small swatch (which would make the
    // text unreadable against a saturated background).  Width is
    // borrowed from the swatch+label reserve region.
    if (opts.display_format == .hex) {
        var hex_buf: [10]u8 = undefined;
        const rgba: [4]f32 = .{
            value[0],
            value[1],
            value[2],
            if (N >= 4) value[3] else 1.0,
        };
        const hex_text: []const u8 = formatColorRgbHex(rgba, N >= 4, &hex_buf);
        if (hex_text.len > 0) {
            // Center-aligned on the row's full bar area so it
            // reads as a single label, regardless of channel
            // count.
            const text_size: Vec2 = measureTextS(ctx, hex_text);
            const tx: f32 = at[0] + (total_bar_w - text_size[0]) / 2;
            const ty: f32 = at[1] + (bar_h - ctx.style.font_size) / 2;
            drawTextAtS(ctx, .{ tx, ty }, hex_text, Color.toWire(ctx.style.text));
        }
    }

    // Draw the label to the right of the swatch.
    drawTextAtS(
        ctx,
        .{ swatch_rect.x + swatch_size + ctx.style.item_spacing[0], at[1] + (bar_h - ctx.style.font_size) / 2 },
        label,
        Color.toWire(ctx.style.text),
    );

    const consumed = Vec2{ inner_right - at[0], bar_h };
    w.layout.last_item_id = widgetId(ctx, w, label);
    w.layout.last_item_rect = .{ .x = at[0], .y = at[1], .width = consumed[0], .height = consumed[1] };
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, w.layout.last_item_rect);
    markItemPost(ctx, w, any_changed);

    advanceLayout(w, consumed, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return any_changed;
}

// ============================================================================
//  colorPicker - block widget, bar + wheel layouts
// ============================================================================
// Mirrors imgui's `ColorPicker3` / `ColorPicker4`. Larger footprint
// than `colorEdit` (which is the inline 3/4-slider). Two layouts
// selectable via `opts.layout`:
// -.bar: a horizontal hue strip stacked above an SV box.
// -.wheel: a hue ring with an SV box inscribed.
// Generic over `*[3]f32`, `*[4]f32`, `*Color`. Internally we
// convert to HSV for the picker's manipulation, then back to RGB(A)
// on commit. Alpha is edited via an extra slim bar when
// `opts.alpha` is true AND the input has an alpha channel.

// ---- Color picker drawing helpers

// ---- Color picker input handlers

// ---- HSV / RGB conversions: see zm.hsvToRgb3 / zm.rgbToHsv3 (scalar) and
// zm.hsvToRgb / zm.rgbToHsv (Vec4, alpha-preserving).

// ============================================================================
//  plotLines + plotHistogram - sparkline-style data plots
// ============================================================================
// Both go through `plotImpl` which handles the shared scaffolding
// (rect layout, label drawing, hover-to-show-value, overlay text)
// and dispatches on `kind` for the actual sample-rendering loop.

/// Which sample-rendering style the plot uses.
const PlotKind = enum { lines, histogram };

fn plotImpl(
    ctx: *UiContext,
    label: []const u8,
    values: []const f32,
    opts: PlotOpts,
    kind: PlotKind,
) void {
    const w: *Window = ctx.current_window orelse return;
    const dl: *DrawList = &w.draw_list;
    const arena: Allocator = ctx.frame_arena.allocator();

    const padding: Vec2 = ctx.style.frame_padding;
    const label_size: Vec2 = measureTextS(ctx, label);

    // Layout: frame fills available row width minus the label
    // reserve. Height comes from opts (defaults to 80).
    const start: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const label_reserve: f32 = if (label.len > 0) label_size[0] + ctx.style.item_spacing[0] else 0;
    const default_w: f32 = @max(80, inner_right - start[0] - label_reserve);
    const frame_w: f32 = effectiveItemWidth(ctx, opts.width, default_w);
    const frame_h: f32 = if (opts.height > 0) opts.height else 80;
    const frame: Rectangle = .{ .x = start[0], .y = start[1], .width = frame_w, .height = frame_h };

    // Frame background.
    dl.addRectFilled(arena, frame, Color.toWire(ctx.style.frame_bg));

    if (values.len > 0) {
        // Determine value range. If opts.min == opts.max, auto-fit.
        var v_min: f32 = opts.min;
        var v_max: f32 = opts.max;
        if (v_min == v_max) {
            v_min = values[0];
            v_max = values[0];
            for (values[1..]) |v| {
                if (v < v_min) {
                    v_min = v;
                }
                if (v > v_max) {
                    v_max = v;
                }
            }
            // Avoid divide-by-zero on constant input.
            if (v_min == v_max) {
                v_min -= 0.5;
                v_max += 0.5;
            }
        }
        const v_range: f32 = v_max - v_min;

        // Map a sample index → x coordinate, value → y coordinate.
        // y is inverted because screen y grows downward but value
        // is "up = bigger."
        const inner_pad: f32 = 2; // breathing room inside the frame
        const inner_x: f32 = frame.x + inner_pad;
        const inner_y: f32 = frame.y + inner_pad;
        const inner_w: f32 = frame.width - 2 * inner_pad;
        const inner_h: f32 = frame.height - 2 * inner_pad;

        const fill: ColorU32 = Color.toWire(ctx.style.button);

        switch (kind) {
            .lines => {
                // Connected line strip - N-1 segments for N samples.
                // For visibility, draw 1px thickness; could become
                // an opt later.
                if (values.len >= 2) {
                    var prev_x: f32 = inner_x;
                    var prev_y: f32 = inner_y + inner_h - ((values[0] - v_min) / v_range) * inner_h;
                    for (values[1..], 1..) |v, i| {
                        const t: f32 = float(i) / float(values.len - 1);
                        const x: f32 = inner_x + t * inner_w;
                        const y: f32 = inner_y + inner_h - ((v - v_min) / v_range) * inner_h;
                        dl.addLine(arena, .{ prev_x, prev_y }, .{ x, y }, fill, 1);
                        prev_x = x;
                        prev_y = y;
                    }
                }
            },
            .histogram => {
                // One filled column per sample, abutting its
                // neighbours (1px gap if there's room).
                const col_w: f32 = inner_w / float(values.len);
                const gap: f32 = if (col_w > 3) 1 else 0;
                for (values, 0..) |v, i| {
                    const x: f32 = inner_x + col_w * float(i);
                    const norm: f32 = clamp((v - v_min) / v_range, 0, 1);
                    const h: f32 = norm * inner_h;
                    const y: f32 = inner_y + inner_h - h;
                    const draw_w: f32 = @max(1, col_w - gap);
                    dl.addRectFilled(arena, .{ .x = x, .y = y, .width = draw_w, .height = h }, fill);
                }
            },
        }

        // Hover-to-show-value: if the mouse is over the frame, find
        // the sample under the cursor and emit a tooltip via
        // `pending_tooltip`. This piggybacks on the existing
        // setTooltip path so styling stays consistent.
        if (pointInRect(ctx.input.mouse_pos, frame)) {
            const rel: f32 = clamp((ctx.input.mouse_pos[0] - inner_x) / inner_w, 0, 1);
            const idx: usize = @min(
                values.len - 1,
                floori(usize, rel * float(values.len)),
            );
            var tt: TooltipData = .{ .pos = .{ ctx.input.mouse_pos[0] + 16, ctx.input.mouse_pos[1] + 16 } };
            const formatted: []u8 = bufPrint(
                &tt.text_buf,
                "[{d}] {d:.3}",
                .{ idx, values[idx] },
            ) catch tt.text_buf[0..0];
            tt.text_len = formatted.len;
            ctx.pending_tooltip = tt;
        }
    }

    // Overlay text - centered horizontally near the top of the frame.
    if (opts.overlay.len > 0) {
        const ov_size: Vec2 = measureTextS(ctx, opts.overlay);
        const ov_pos: Vec2 = .{ frame.x + (frame.width - ov_size[0]) * 0.5, frame.y + padding[1] };
        drawTextAtS(ctx, ov_pos, opts.overlay, Color.toWire(ctx.style.text));
    }

    // Label after the frame.
    if (label.len > 0) {
        const label_pos: Vec2 = .{
            frame.x + frame.width + ctx.style.item_spacing[0],
            frame.y + (frame.height - ctx.style.font_size) * 0.5,
        };
        drawTextAtS(ctx, label_pos, label, Color.toWire(ctx.style.text));
    }

    w.layout.last_item_id = widgetId(ctx, w, label);
    w.layout.last_item_rect = .{
        .x = frame.x,
        .y = frame.y,
        .width = frame.width + label_reserve,
        .height = frame.height,
    };
    w.layout.last_item_hovered = pointInRect(ctx.input.mouse_pos, frame);
    markItemPost(ctx, w, false);

    advanceLayout(
        w,
        .{ frame.width + label_reserve, frame.height },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
}

// ============================================================================
//  tables - layout core
// ============================================================================
// Single-slot state on UiContext (no nested tables in ).
// Width resolution: sum the fixed columns' user widths, give the
// remainder to stretch columns weighted by `user_weight`. Rows
// are submitted top-to-bottom; each row's height is the max of
// the heights submitted by every cell. Borders render at row /
// column boundaries in the cell-clipping replay path.

/// Derive a per-column sizing default from a table-level policy.
/// Stretch policies imply stretch columns; fixed policies imply
/// fixed columns. Mirrors imgui's TableSetupColumnFlags logic at
/// imgui_tables.cpp:790, which sets `WidthFixed` for `FixedFit` /
/// `FixedSame` table policies and `WidthStretch` for the stretch
/// policies.
fn defaultColumnSizing(table_sizing: TableSizing) TableColumnSizing {
    return switch (table_sizing) {
        .stretch_same, .stretch_prop => .stretch,
        .fixed_fit, .fixed_same => .fixed,
    };
}

/// First-frame fallback width when no `width_auto_seen` measurement
/// exists yet. A column that's just appeared has no measured
/// content width — return a sensible default proportional to the
/// font size so the column is at least readable. Six em is roughly
/// "a short noun or small number", a common dashboard cell width.
fn fallbackColumnWidth(font_size: f32) f32 {
    return 6 * font_size;
}

/// Resolve the actual width to use for a fixed column under the
/// active table sizing policy.
///   - If the caller passed `width` to `tableSetupColumn`, that
///     value wins (any policy).
///   - Otherwise `fixed_same` returns `fixed_max_w_auto` (the max
///     measured width across all fixed columns).
///   - Otherwise (any other policy with a fixed column, primarily
///     `fixed_fit` or `stretch_*` with a column-level fixed
///     override) returns the column's OWN measured width.
fn effectiveFixedWidth(
    c: *const TableColumnState,
    table_sizing: TableSizing,
    fixed_max_w_auto: f32,
) f32 {
    if (c.user_width > 0) {
        return c.user_width;
    }
    if (table_sizing == .fixed_same) {
        return fixed_max_w_auto;
    }
    return c.width_auto_seen;
}

/// Resolve the stretch weight for a stretch column under the
/// active table sizing policy.
///   - User-supplied weight wins (any policy).
///   - Otherwise `stretch_prop` returns the column's measured
///     content width — proportional sharing.
///   - Otherwise returns 1.0 (equal sharing for `stretch_same`,
///     and the same default for any mixed-policy case).
fn effectiveStretchWeight(c: *const TableColumnState, table_sizing: TableSizing) f32 {
    if (c.user_weight) |w| {
        return w;
    }
    if (table_sizing == .stretch_prop) {
        return c.width_auto_seen;
    }
    return 1.0;
}

/// Compute final per-column widths + x offsets. Called at the
/// first `tableNextRow` (or `tableHeadersRow` in ) once
/// all setup is done. Idempotent — calling twice produces the
/// same result.
///
/// P8.4: implements four sizing modes (see `TableSizing`).
/// The pipeline:
///   1. Fill defaults for any column the caller didn't configure
///      via `tableSetupColumn` — inherits sizing from the table's
///      policy.
///   2. Backfill `width_auto_seen` from the first-frame fallback
///      where no measurement exists yet.
///   3. Compute `fixed_max_w_auto` (max measured fixed width) when
///      the policy is `fixed_same`, since every fixed column will
///      use that single value.
///   4. Sum fixed widths and stretch weights using the helpers
///      `effectiveFixedWidth` / `effectiveStretchWeight` — these
///      encapsulate the user-override-wins rule and the per-policy
///      defaults.
///   5. Lay out columns left-to-right, distributing the remaining
///      pixels among stretch columns proportional to their weights.
///   6. Lint: warn if the cumulative column width overflows the
///      outer rect (the min-width floor protects each column but
///      can push the last column past the right edge).
fn computeTableColumnLayout(ctx: *UiContext, ts: *TableState) void {
    // [1] Fill defaults for any column not configured via
    //     tableSetupColumn. These columns inherit sizing from the
    //     table-level policy.
    const default_col_sizing: TableColumnSizing = defaultColumnSizing(ts.opts.sizing);
    while (ts.columns_set_up < ts.n_columns) : (ts.columns_set_up += 1) {
        ts.columns[ts.columns_set_up] = .{ .sizing = default_col_sizing };
    }

    // [2] First-frame fallback for unmeasured columns. A column
    //     with `width_auto_seen == 0` either appeared this frame
    //     for the first time or no widget ever set cursor_max[0]
    //     past col.x (empty cell). Either way, fall back to the
    //     font-size heuristic so the column has SOME width to
    //     work with.
    const fallback_w: f32 = fallbackColumnWidth(ctx.style.font_size);
    var i: u32 = 0;
    while (i < ts.n_columns) : (i += 1) {
        if (ts.columns[i].width_auto_seen <= 0) {
            ts.columns[i].width_auto_seen = fallback_w;
        }
    }

    // [3] For `fixed_same` only: find the max content width across
    //     all fixed columns. Every fixed column will use this
    //     single value (unless the caller overrode `user_width`).
    var fixed_max_w_auto: f32 = 0;
    if (ts.opts.sizing == .fixed_same) {
        i = 0;
        while (i < ts.n_columns) : (i += 1) {
            const c: *const TableColumnState = &ts.columns[i];
            if (c.sizing == .fixed and c.width_auto_seen > fixed_max_w_auto) {
                fixed_max_w_auto = c.width_auto_seen;
            }
        }
    }

    // [4] Sum the fixed columns' resolved widths and the stretch
    //     columns' resolved weights. The two helpers encapsulate
    //     the priority chain — user override > policy default.
    var fixed_total: f32 = 0;
    var stretch_weight_sum: f32 = 0;
    i = 0;
    while (i < ts.n_columns) : (i += 1) {
        const c: *const TableColumnState = &ts.columns[i];
        switch (c.sizing) {
            .fixed => fixed_total += effectiveFixedWidth(c, ts.opts.sizing, fixed_max_w_auto),
            .stretch => stretch_weight_sum += effectiveStretchWeight(c, ts.opts.sizing),
        }
    }

    // [5] Lay out columns left-to-right. Stretch columns split the
    //     pixels left over after subtracting the fixed total,
    //     proportional to their weights. Each column is floored at
    //     `2 * cell_padding_x + 8` so cell content always has at
    //     least 8px of room — pre-floor preserved.
    const cell_pad_x: f32 = ts.opts.cell_padding_x;
    const remaining: f32 = ts.outer_rect.width - fixed_total;
    const min_w: f32 = 2 * cell_pad_x + 8;
    var x: f32 = ts.outer_rect.x;
    i = 0;
    while (i < ts.n_columns) : (i += 1) {
        var c: *TableColumnState = &ts.columns[i];
        var width: f32 = switch (c.sizing) {
            .fixed => effectiveFixedWidth(c, ts.opts.sizing, fixed_max_w_auto),
            .stretch => blk: {
                if (stretch_weight_sum > 0) {
                    const w_eff: f32 = effectiveStretchWeight(c, ts.opts.sizing);
                    break :blk remaining * (w_eff / stretch_weight_sum);
                }
                break :blk 0;
            },
        };
        if (width < min_w) {
            width = min_w;
        }
        c.width = width;
        c.x = x;
        x += width;
    }

    // [6] lint #3: column overflow. The min-width floor above
    //     protects every column from collapsing to 0px, but the cost
    //     is that a table with too many columns for its outer rect
    //     now has its last column run PAST the outer right edge —
    //     cell content gets clipped or runs into adjacent widgets.
    //     Detectable by comparing the cumulative column x against
    //     the outer right edge. Catches the "I put 8 columns in a
    //     200px window" bug.
    //

    //     P8.5: suppress the warning when `scroll_x` is
    //     enabled. With horizontal scrolling on, the column total
    //     EXCEEDING the outer width is the explicit intent — that's
    //     what you'd scroll horizontally to see. No more overflow
    //     warnings in that case.
    const outer_right: f32 = ts.outer_rect.x + ts.outer_rect.width;
    if (!ts.opts.scroll_x and x > outer_right + 0.5) { // half-pixel slack for float rounding
        warnOnce(
            @src(),
            "ui.beginTable: column total {d:.0}px overflows outer width {d:.0}px ({d} columns at min {d:.0}px each)",
            .{ x - ts.outer_rect.x, ts.outer_rect.width, ts.n_columns, min_w },
        );
    }
}

fn tableNextRowImpl(ctx: *UiContext) void {
    const ts: *TableState = if (ctx.active_table) |*t| t else return;
    const arena: Allocator = ctx.frame_arena.allocator();

    if (ts.cur_row < 0) {
        // First row: lock the column layout.
        computeTableColumnLayout(ctx, ts);
        // If `tableHeadersRow` wasn't called, the data region
        // starts at the table's top.
        if (ts.data_origin_y == ts.outer_rect.y) {
            ts.data_origin_y = ts.outer_rect.y;
        }
        // Apply the persistent scroll offset to row_y so the first
        // data row lands where the user scrolled it. When
        // outer_height > 0 this is the scrollable path.
        if (ts.opts.outer_height > 0) {
            ts.row_y = ts.data_origin_y - ts.scroll_y;
            // Clip data rows to the area between data_origin_y and
            // the outer-rect bottom. Header (above data_origin_y)
            // stays outside the clip → sticky behavior.
            const clip_rect: Rectangle = .{
                .x = ts.outer_rect.x,
                .y = ts.data_origin_y,
                .width = ts.outer_rect.width,
                .height = (ts.outer_rect.y + ts.outer_rect.height) - ts.data_origin_y,
            };
            // P7.2: push clip on BOTH channels so the merged
            // stream has clip → bg → pop on channel 0 AND
            // clip → content → pop on channel 1. Both bg and
            // content end up inside their respective clip rect
            // pair after merge.
            ts.splitter.channels.items[0].pushClipRect(arena, clip_rect);
            ts.splitter.channels.items[1].pushClipRect(arena, clip_rect);
            ts.clip_pushed = true;
        } else {
            ts.row_y = ts.data_origin_y;
        }
    } else {
        // Subsequent rows: finalize the previous row's geometry.
        //

        // / T1.1 (turns 412-413): snapshot the previous row's
        // LAST cell here. `tableNextColumnImpl` only snapshots
        // when advancing WITHIN a row, so without this call the
        // final column's metrics would never be measured.
        //

        // Width has been captured this way since P8.4;
        // height is added in T1.1. Pre-T1.1, a row
        // whose tallest cell was the rightmost column ended up
        // SHORTER than its content (row_max_h reflected only
        // cells 0..N-2), causing the last column's content to
        // overflow visually below the row bg. Snapshotting both
        // metrics here closes that gap. Snapshot order is
        // immaterial; both are pure max-update operations.
        if (ts.cur_col >= 0) {
            if (ctx.current_window) |w| {
                snapshotCellHeight(ts, w);
                snapshotCellWidth(ts, w);
            }
        }
        const row_bottom: f32 = ts.row_y + ts.row_max_h;
        // P7.2: row bg goes into splitter channel 0;
        // cell content (already submitted via current_draw_list →
        // channel 1) is independent. On merge, channel 0 lands
        // BEFORE channel 1 in the window's draw_list, so the bg
        // paints UNDER the content — proper z-order without index
        // manipulation, and fully-opaque bg colors now work.
        drawTableRowBg(ctx, ts, ts.row_y, ts.row_max_h, @as(u32, @intCast(ts.cur_row)));
        // P8.1: cell bg overrides for each column in
        // the just-finished row. Painted AFTER the row bg so
        // cell-level overrides win visually (more specific →
        // higher priority). Cleared after painting so the next
        // row's cells start without stale overrides.
        flushTableCellBgs(ctx, ts, ts.row_y, ts.row_max_h);
        // Optional inter-row border — also goes on channel 0
        // (border is bg-layer; visually under content).
        // P8.2: gated by both `borders` (master) AND
        // `borders_inner_h` (the granular flag for horizontal
        // interior lines).
        if (ts.opts.borders and ts.opts.borders_inner_h) {
            ts.splitter.channels.items[0].addLine(
                arena,
                .{ ts.outer_rect.x, row_bottom },
                .{ ts.outer_rect.x + ts.outer_rect.width, row_bottom },
                Color.toWire(ctx.style.border),
                1,
            );
        }
        ts.row_y = row_bottom;
    }
    ts.cur_row += 1;
    ts.cur_col = -1;
    ts.row_max_h = 0;
    // Reset per-row override. User calls tableSetRowBgColor right
    // after tableNextRow to set this for the new row.
    ts.row_bg_override = null;
}

/// Cycle the sort state for the table whose id is `id` when the
/// user clicks `col_idx`. Shift-click semantics:
/// - Plain click on a column NOT in the spec → it becomes the new
///   primary, ascending. Anything else is wiped.
/// - Plain click on the current primary → flip its direction.
/// - Plain click on a non-primary already in the spec → it
///   promotes to primary (the rest are wiped).
/// - Shift-click on a column not in the spec → appended as a new
///   secondary (or tertiary; capped at len 3 - beyond that, the
///   new column REPLACES the primary, demoting the others by one
///   slot).
/// - Shift-click on a column already in the spec → flip that
///   entry's direction in place.
fn cycleTableSort(
    ctx: *UiContext,
    id: Id,
    col_idx: u32,
    additive: bool,
    col: *const TableColumnState,
) void {
    const gop: std.AutoHashMapUnmanaged(Id, TableSortSpecs).GetOrPutResult = ctx.table_sort_state.getOrPut(
        ctx.gpa,
        id,
    ) catch return;
    if (!gop.found_existing) {
        gop.value_ptr.* = .{};
    }
    const ss: *TableSortSpecs = gop.value_ptr;

    // Compute the first allowed direction for a fresh activation
    // on this column.  Ascending is the natural starting point;
    // fall back to descending when ascending is blocked.  If both
    // are blocked, the caller already filtered the click out, so
    // this branch shouldn't fire — but the @max guards keep us
    // honest if it does.
    const initial_dir: TableSortDirection = if (col.no_sort_ascending)
        .descending
    else
        .ascending;

    // Find existing index of col_idx in the spec.
    var existing: ?usize = null;
    for (ss.specs[0..ss.len], 0..) |s, i| {
        if (s.column_index == col_idx) {
            existing = i;
            break;
        }
    }

    // Flip a direction, skipping directions blocked by the
    // column's flags.  Cycle: asc → desc → asc → ...  When one
    // direction is blocked, the cycle collapses to the other
    // direction only (clicks effectively become "stay where
    // you are" — but still re-promote to primary if shifted).
    const flip = struct {
        fn run(d: TableSortDirection, no_asc: bool, no_desc: bool) TableSortDirection {
            const target: TableSortDirection = switch (d) {
                .ascending => .descending,
                .descending => .ascending,
            };
            // If the natural flip lands on a blocked direction,
            // stay on the current one (better than skipping
            // through to a no-change).
            if (target == .ascending and no_asc) {
                return d;
            }
            if (target == .descending and no_desc) {
                return d;
            }
            return target;
        }
    }.run;

    if (!additive) {
        // Plain click — single-column sort, or flip on primary.
        if (existing) |idx| {
            if (idx == 0) {
                ss.specs[0].direction = flip(
                    ss.specs[0].direction,
                    col.no_sort_ascending,
                    col.no_sort_descending,
                );
                ss.len = 1;
                return;
            }
            // Promote to primary, wipe others.
            ss.specs[0] = .{ .column_index = col_idx, .direction = initial_dir };
            ss.len = 1;
            return;
        }
        // New column → primary at its allowed initial direction.
        ss.specs[0] = .{ .column_index = col_idx, .direction = initial_dir };
        ss.len = 1;
        return;
    }

    // Additive (shift-click).
    if (existing) |idx| {
        // Already in the spec → flip its direction in place.
        ss.specs[idx].direction = flip(
            ss.specs[idx].direction,
            col.no_sort_ascending,
            col.no_sort_descending,
        );
        return;
    }
    // Append new column.  If len < 3, append at the tail.  If
    // len == 3 (full), new column replaces the primary; existing
    // primary + secondary shift down (oldest tertiary falls off).
    if (ss.len < 3) {
        ss.specs[ss.len] = .{ .column_index = col_idx, .direction = initial_dir };
        ss.len += 1;
        return;
    }
    // Full: rotate.  specs[2] is discarded, specs[1] becomes [2],
    // specs[0] becomes [1], new column is at [0].
    ss.specs[2] = ss.specs[1];
    ss.specs[1] = ss.specs[0];
    ss.specs[0] = .{ .column_index = col_idx, .direction = initial_dir };
}

/// Tiny solid triangle pointing up (ascending) or down
/// (descending). Drawn via three filled triangles for simplicity.
fn drawSortArrow(
    dl: *DrawList,
    alloc: Allocator,
    x: f32,
    y: f32,
    size: f32,
    dir: TableSortDirection,
    col: ColorU32,
) void {
    switch (dir) {
        .ascending => {
            // Tip at top, base at bottom.
            dl.addTriangleFilled(
                alloc,
                .{ x + size * 0.5, y },
                .{ x + size, y + size },
                .{ x, y + size },
                col,
            );
        },
        .descending => {
            // Base at top, tip at bottom.
            dl.addTriangleFilled(
                alloc,
                .{ x, y },
                .{ x + size, y },
                .{ x + size * 0.5, y + size },
                col,
            );
        },
    }
}

fn tableHeadersRowImpl(ctx: *UiContext) void {
    const ts: *TableState = if (ctx.active_table) |*t| t else return;
    if (ts.cur_row >= 0) {
        // Headers must be drawn before any data row.
        return;
    }

    // Lock the column layout so header rects match data-cell rects.
    computeTableColumnLayout(ctx, ts);

    const w: *Window = ctx.current_window orelse return;
    const dl: *DrawList = &w.draw_list;
    const arena: Allocator = ctx.frame_arena.allocator();

    const padding: Vec2 = ctx.style.frame_padding;
    const header_h: f32 = ctx.style.font_size + 2 * padding[1];
    const header_bg: ColorU32 = Color.toWire(ctx.style.frame_bg_active);
    const header_text_col: ColorU32 = Color.toWire(ctx.style.text);
    const border_col: ColorU32 = Color.toWire(ctx.style.border);

    // Look up existing sort spec for this table (may be null).
    const sort_specs_opt: ?*const TableSortSpecs = if (ctx.table_sort_state.getPtr(ts.id)) |p| p else null;

    var i: u32 = 0;
    while (i < ts.n_columns) : (i += 1) {
        const col: *const TableColumnState = &ts.columns[i];
        const cell: Rectangle = .{ .x = col.x, .y = ts.outer_rect.y, .width = col.width, .height = header_h };

        // Background.
        dl.addRectFilled(arena, cell, header_bg);

        // Click handling — suppressed when `no_sort` is set OR
        // when both direction flags block every available cycle
        // step (no_sort_ascending + no_sort_descending = nothing
        // to cycle to).
        const blocked_all: bool = col.no_sort_ascending and col.no_sort_descending;
        if (!col.no_sort and !blocked_all) {
            const hovered: bool = pointInRect(ctx.input.mouse_pos, cell);
            if (hovered and ctx.input.mouse_left_clicked) {
                cycleTableSort(ctx, ts.id, i, snapshotShiftDown(&ctx.input), col);
            }
        }

        // Label (truncated visually by clip - will add
        // explicit per-cell clipping; for now the cell is wide
        // enough in practice). P8.3: header label respects the
        // same no_pad_outer_x / no_pad_inner_x gates as
        // tableNextColumn does for data cells, so the header
        // stays vertically aligned with its column's content.
        const header_left_pad: f32 = if (i == 0)
            (if (ts.opts.no_pad_outer_x) 0 else ts.opts.cell_padding_x)
        else
            (if (ts.opts.no_pad_inner_x) 0 else ts.opts.cell_padding_x);
        // P8.6 part 2: `no_header_label` suppresses the text but
        // keeps everything else — the header bar still renders,
        // sort cycling still works, and the sort arrow still
        // appears if the column is in the spec.  Useful for
        // icon-only headers or unlabeled columns in merged
        // header surfaces.
        if (!col.no_header_label) {
            const label_pos: Vec2 = .{ cell.x + header_left_pad, cell.y + padding[1] };
            drawTextAtS(ctx, label_pos, col.name(), header_text_col);
        }

        // Sort indicator if this column is in the spec. Small
        // up/down triangle to the right of the cell.
        if (sort_specs_opt) |ss| {
            for (ss.specs[0..ss.len], 0..) |spec, rank| {
                if (spec.column_index == i) {
                    const arrow_size: f32 = 6;
                    const ax: f32 = cell.x + cell.width - ts.opts.cell_padding_x - arrow_size;
                    const ay: f32 = cell.y + (cell.height - arrow_size) * 0.5;
                    drawSortArrow(dl, arena, ax, ay, arrow_size, spec.direction, header_text_col);
                    // For secondary/tertiary, a tiny rank pip
                    // just below. Optional UX flair.
                    if (rank > 0) {
                        var rank_buf: [4]u8 = undefined;
                        const rs = bufPrint(&rank_buf, "{d}", .{rank + 1}) catch rank_buf[0..0];
                        drawTextAtS(ctx, .{ ax - 8, cell.y + padding[1] }, rs, header_text_col);
                    }
                    break;
                }
            }
        }

        // Right border of cell — P8.2: `borders_inner_v`.
        // (Header per-cell vertical dividers; gated by master too.)
        if (ts.opts.borders and ts.opts.borders_inner_v and i + 1 < ts.n_columns) {
            dl.addLine(
                arena,
                .{ cell.x + cell.width, cell.y },
                .{ cell.x + cell.width, cell.y + cell.height },
                border_col,
                1,
            );
        }
    }

    // Bottom border of the header row — P8.2: `borders_inner_h`
    // (the line BETWEEN header and the first data row is interior
    // horizontal, not part of the outer rect).
    if (ts.opts.borders and ts.opts.borders_inner_h) {
        dl.addLine(
            arena,
            .{ ts.outer_rect.x, ts.outer_rect.y + header_h },
            .{ ts.outer_rect.x + ts.outer_rect.width, ts.outer_rect.y + header_h },
            border_col,
            1,
        );
    }

    // Advance the table's row_y so the first data row sits below
    // the header. Doesn't bump cur_row - that stays at -1 so the
    // user's first tableNextRow becomes row 0. Also record this y
    // as the data-region origin: scrollable area starts here.
    ts.row_y += header_h;
    ts.data_origin_y = ts.row_y;
}

// ============================================================================
// Tooltip rendering - runs at endFrame on top of everything else.
// ============================================================================

/// Replay frame windows in z-order, drawing the dock tab-bar layer right before
/// the first floating window (so floating windows + headers sit above dock tabs).
fn replayWindowsAndTabs(
    ctx: *UiContext,
    gl: *Gl,
    window: *const runtime.core.WindowState,
    shapes_state: *const shapes2d.ShapesTextureState,
    font_cache: *const text2d.FontCache,
) void {
    var tabs_done: bool = false;
    for (ctx.frame_windows.items[0..ctx.frame_windows.len]) |w| {
        if (!tabs_done and windowIsFloating(w)) {
            ctx.dock_tabs_dl.render(gl, window, shapes_state, font_cache);
            tabs_done = true;
        }
        w.draw_list.render(gl, window, shapes_state, font_cache);
    }
    if (!tabs_done) {
        ctx.dock_tabs_dl.render(gl, window, shapes_state, font_cache);
    }
}

/// Render the tooltip's frame background + replay its draw list.
/// Called at endFrame after every other draw list - the tooltip
/// must overlay popups + foreground.
fn renderTooltipBlock(
    ctx: *UiContext,
    gl: *Gl,
    window: *const runtime.core.WindowState,
    shapes_state: *const shapes2d.ShapesTextureState,
    font_cache: *const text2d.FontCache,
) void {
    if (!ctx.tooltip_active_this_frame) {
        return;
    }
    const tw: *Window = ctx.tooltip_window orelse return;
    // Inject background + border as the FIRST two cmds. We do this
    // here (not in beginTooltip) because we need the final size,
    // which is only known after content is laid out.
    // Trick: push the bg/border into foreground_dl, then replay
    // the tooltip's own draw list on top. This avoids reshuffling
    // tw.draw_list.cmds, which is append-only by design.
    const rect: Rectangle = .{
        .x = tw.pos[0],
        .y = tw.pos[1],
        .width = tw.size[0],
        .height = tw.size[1],
    };
    const arena: Allocator = ctx.frame_arena.allocator();
    ctx.foreground_dl.addRectFilled(arena, rect, Color.toWire(ctx.style.window_bg));
    ctx.foreground_dl.addRectOutline(arena, rect, Color.toWire(ctx.style.border));
    // Render the bg + outline NOW so it sits beneath the content.
    ctx.foreground_dl.render(gl, window, shapes_state, font_cache);
    ctx.foreground_dl.clear();
    ctx.dock_tabs_dl.clear();

    // Now replay the tooltip's content on top.
    tw.draw_list.render(gl, window, shapes_state, font_cache);
}

/// Render the drag preview at endFrame, after foreground_dl.
/// Symmetric with renderTooltipBlock - different just so the
/// preview gets its own clear bg (slightly tinted with the accent
/// color so it visually reads as "this is being dragged").
fn renderDragPreview(
    ctx: *UiContext,
    gl: *Gl,
    window: *const runtime.core.WindowState,
    shapes_state: *const shapes2d.ShapesTextureState,
    font_cache: *const text2d.FontCache,
) void {
    if (!ctx.drag_preview_active_this_frame) {
        return;
    }
    const dw: *Window = ctx.drag_preview_window orelse return;
    const rect: Rectangle = .{
        .x = dw.pos[0],
        .y = dw.pos[1],
        .width = dw.size[0],
        .height = dw.size[1],
    };
    const arena: Allocator = ctx.frame_arena.allocator();
    // Tinted bg: window_bg with a touch of the accent. Subtle enough
    // not to fight the preview text but distinct from tooltip chrome.
    ctx.foreground_dl.addRectFilled(arena, rect, Color.toWire(ctx.style.window_bg));
    ctx.foreground_dl.addRectOutline(arena, rect, Color.toWire(ctx.style.button_active));
    ctx.foreground_dl.render(gl, window, shapes_state, font_cache);
    ctx.foreground_dl.clear();
    ctx.dock_tabs_dl.clear();
    dw.draw_list.render(gl, window, shapes_state, font_cache);
}

pub fn uiRenderNow(
    ctx: *UiContext,
    gl: *Gl,
    window: *const runtime.core.WindowState,
    shapes_state: *const shapes2d.ShapesTextureState,
    font_cache: *const text2d.FontCache,
) void {
    ctx.background_dl.render(gl, window, shapes_state, font_cache);
    replayWindowsAndTabs(ctx, gl, window, shapes_state, font_cache);
    for (ctx.frame_popups.items[0..ctx.frame_popups.len]) |p| {
        p.draw_list.render(gl, window, shapes_state, font_cache);
    }
    ctx.foreground_dl.render(gl, window, shapes_state, font_cache);
    renderTooltipBlock(ctx, gl, window, shapes_state, font_cache);
    renderDragPreview(ctx, gl, window, shapes_state, font_cache);
}

fn uiContextDeferredRender(data: *anyopaque, _: *Gl) void {
    const ctx: *UiContext = @ptrCast(@alignCast(data));
    const gl: *Gl = ctx.frame_gl orelse return;
    const win: *const runtime.core.WindowState = ctx.frame_window orelse return;
    const shapes_state: *const shapes2d.ShapesTextureState = ctx.frame_shapes_state orelse return;
    const font_cache: *const text2d.FontCache = ctx.frame_font_cache orelse return;

    ctx.background_dl.render(gl, win, shapes_state, font_cache);
    replayWindowsAndTabs(ctx, gl, win, shapes_state, font_cache);
    for (ctx.frame_popups.items[0..ctx.frame_popups.len]) |p| {
        p.draw_list.render(gl, win, shapes_state, font_cache);
    }
    ctx.foreground_dl.render(gl, win, shapes_state, font_cache);
    renderTooltipBlock(ctx, gl, win, shapes_state, font_cache);
    renderDragPreview(ctx, gl, win, shapes_state, font_cache);
}

// ============================================================================
//  drag-drop source side
// ============================================================================
// State lives on `UiContext.drag_drop` (single-slot - only one drag
// in flight system-wide). The state machine ticks in `beginFrame`
// (see DragDropState comment). Here are the three public-impl fns
// plus the preview-render trampoline used at endFrame.

// ============================================================================
// Widget: drag - comptime dispatch on numeric pointer types
// ============================================================================
// Mechanically a slider with no fixed bar width: while active, each
// pixel of mouse-X delta translates to `opts.speed` units of value
// change. Optional clamping via `opts.min`/`opts.max` (set both to
// zero to disable).

/// Multi-component drag: N narrow bars side-by-side, each editing
/// `arr_ptr[i]`. Only one bar can be the drag-active one at a time
/// (one mouse), so the existing `active_id_press_x` /
/// `active_id_press_value` state suffices - keyed by per-bar id.
fn dragArray(
    ctx: *UiContext,
    label: []const u8,
    arr_ptr: anytype,
    comptime T: type,
    comptime n: usize,
    opts: DragOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const parent_id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const inner_spacing: f32 = 4;
    const label_size: Vec2 = measureTextS(ctx, label);
    const inner_w: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0] - resolveCursor(
        w,
        ctx.style.item_spacing[0],
    )[0];
    const label_reserve: f32 = label_size[0] + ctx.style.item_spacing[0];
    const gaps: f32 = inner_spacing * float(n - 1);
    const default_total_w: f32 = inner_w - label_reserve - gaps;
    var total_w: f32 = effectiveItemWidth(ctx, opts.width, default_total_w);
    if (total_w < 60 * float(n)) {
        total_w = 60 * float(n);
    }
    const bar_w: f32 = total_w / float(n);
    const bar_h: f32 = ctx.style.font_size + 2 * padding[1];

    var changed: bool = false;
    var any_hovered: bool = false;
    const start: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    inline for (0..n) |i| {
        const id: Id = hashInt(parent_id, @intCast(i));
        const x: f32 = start[0] + (bar_w + inner_spacing) * float(i);
        const bar: Rectangle = .{ .x = x, .y = start[1], .width = bar_w, .height = bar_h };

        const hovered: bool = pointInRect(ctx.input.mouse_pos, bar);
        if (hovered) {
            ctx.hovered_id = id;
            any_hovered = true;
        }
        if (hovered and ctx.input.mouse_left_clicked) {
            ctx.active_id = id;
            ctx.active_id_just_activated = true;
        }
        if (ctx.active_id == id and !ctx.input.mouse_left_down) {
            ctx.active_id = 0;
        }
        if (ctx.active_id == id and ctx.active_id_just_activated) {
            ctx.active_id_press_x = ctx.input.mouse_pos[0];
            ctx.active_id_press_value = scalarToF64(T, arr_ptr[i]);
        }
        if (ctx.active_id == id) {
            const cur_f: f64 = scalarToF64(T, arr_ptr[i]);
            const dx: f32 = ctx.input.mouse_pos[0] - ctx.active_id_press_x;
            const new_f: f64 = ctx.active_id_press_value + @as(f64, dx) * @as(f64, opts.speed);
            const clamped: f64 = if (opts.min == opts.max) new_f else clamp(new_f, opts.min, opts.max);
            if (clamped != cur_f) {
                arr_ptr[i] = scalarFromF64(T, clamped);
                changed = true;
            }
        }

        // Background colour by state.
        const bg: Color = if (ctx.active_id == id)
            ctx.style.frame_bg_active
        else if (hovered)
            ctx.style.frame_bg_hovered
        else
            ctx.style.frame_bg;
        drawRectFilled(ctx, bar, Color.toWire(bg));

        // Value text centred inside.
        var val_buf: [32]u8 = undefined;
        const val_str = formatScalar(&val_buf, T, arr_ptr[i], opts.fmt);
        const val_size: Vec2 = measureTextS(ctx, val_str);
        drawTextAtS(
            ctx,
            .{ bar.x + (bar.width - val_size[0]) * 0.5, bar.y + padding[1] },
            val_str,
            Color.toWire(ctx.style.text),
        );
    }

    const total_consumed_w: f32 = total_w + gaps;
    drawTextAtS(
        ctx,
        .{ start[0] + total_consumed_w + ctx.style.item_spacing[0], start[1] + padding[1] },
        label,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = parent_id;
    w.layout.last_item_rect = .{
        .x = start[0],
        .y = start[1],
        .width = total_consumed_w + label_reserve,
        .height = bar_h,
    };
    w.layout.last_item_hovered = any_hovered;
    markItemPost(ctx, w, changed);
    markItemNavigable(ctx, w, parent_id);

    advanceLayout(
        w,
        .{ total_consumed_w + label_reserve, bar_h },
        ctx.style.item_spacing,
        ctx.style.window_padding[0],
    );
    return changed;
}

// ============================================================================
// Widget: radioButton
// ============================================================================
// Tiny circle (rendered as a filled rect for - simpler than a
// real anti-aliased disc; still readable) + label. Returns true on
// the click frame; ALSO writes `*current = value` on click so the
// caller can ignore the bool.

fn radioButtonImpl(
    ctx: *UiContext,
    label: []const u8,
    current: *i32,
    value: i32,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = hashInt(widgetId(ctx, w, label), value);

    const dot_size: f32 = ctx.style.font_size + 4;
    const text_size: Vec2 = measureTextS(ctx, label);
    const total_w: f32 = dot_size + ctx.style.item_spacing[0] + text_size[0];
    const total_h: f32 = dot_size;

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const rect = Rectangle{ .x = at[0], .y = at[1], .width = total_w, .height = total_h };
    const dot_rect = Rectangle{ .x = at[0], .y = at[1], .width = dot_size, .height = dot_size };

    const u: Ui = .{ .ctx = ctx };
    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, .{ total_w, total_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const r: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    var changed: bool = false;
    if (r.pressed and current.* != value) {
        current.* = value;
        changed = true;
    }

    // Outer ring (square for now — circle drawing is a polish item).
    const ring_bg: Color = if (r.held)
        ctx.style.frame_bg_active
    else if (r.hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, dot_rect, Color.toWire(ring_bg));
    // Inset center when selected.
    if (current.* == value) {
        const inset: f32 = 4;
        drawRectFilled(
            ctx,
            .{
                .x = dot_rect.x + inset,
                .y = dot_rect.y + inset,
                .width = dot_size - 2 * inset,
                .height = dot_size - 2 * inset,
            },
            Color.toWire(ctx.style.button_active),
        );
    }

    drawTextAtS(
        ctx,
        .{ dot_rect.x + dot_size + ctx.style.item_spacing[0], at[1] + (dot_size - ctx.style.font_size) / 2 },
        label,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = r.hovered;
    markItemPost(ctx, w, changed);
    markItemNavigable(ctx, w, id);

    advanceLayout(w, .{ total_w, total_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return changed;
}

// ============================================================================
// Widget: selectable
// ============================================================================
// Full-row clickable text with a colored highlight when selected or
// hovered. Foundation for menu items, file pickers, list selectors.
// Returns true on the click frame (caller decides what `selected`
// means and toggles their own state).

fn multiSelectItemHeader(
    ctx: *UiContext,
    _: Id,
    selected: bool,
) bool {
    const scope: *MultiSelectScope = &(ctx.current_multi_select orelse return selected);
    if (!scope.is_focused) {
        return selected;
    }

    return switch (scope.loop_request_set_all) {
        .none => selected,
        .clear => false,
        .select => true,
    };
}

fn selectableImpl(
    ctx: *UiContext,
    label: []const u8,
    selected: bool,
    opts: SelectableOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    // Override `selected` when a multi-select scope is active and a
    // SetAll request was emitted at Begin (Escape clear, Ctrl+A) —
    // this frame's render needs to match the pending request.
    // Pass-through otherwise.
    const effective_selected: bool = multiSelectItemHeader(ctx, id, selected);

    const padding: Vec2 = ctx.style.frame_padding;
    const row_h: f32 = ctx.style.font_size + 2 * padding[1];
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const row_w: f32 = effectiveItemWidth(ctx, opts.width, inner_right - at[0]);

    const rect = Rectangle{ .x = at[0], .y = at[1], .width = row_w, .height = row_h };

    const u: Ui = .{ .ctx = ctx };
    if (!u.itemAdd(rect, id)) {
        advanceLayout(w, .{ row_w, row_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
        return false;
    }

    const br: Ui.ButtonResult = u.buttonBehavior(rect, id, .{});
    const clicked: bool = br.pressed;

    // Multi-select footer: emit click requests per the truth table
    // in `src/notes/multiselect-tutorial.md` § 9. No-op outside a
    // multi-select scope or when this item wasn't tagged with
    // `setNextItemSelectionUserData`.
    multiSelectItemFooter(ctx, id, effective_selected, clicked);

    if (effective_selected or br.hovered or br.held) {
        const bg: Color = if (br.held)
            ctx.style.button_active
        else if (effective_selected)
            ctx.style.button_hovered
        else
            ctx.style.button;
        drawRectFilled(ctx, rect, Color.toWire(bg));
    }

    drawTextAtS(
        ctx,
        .{ at[0] + padding[0], at[1] + padding[1] },
        label,
        Color.toWire(ctx.style.text),
    );

    w.layout.last_item_id = id;
    w.layout.last_item_rect = rect;
    w.layout.last_item_hovered = br.hovered;
    markItemPost(ctx, w, false); // caller owns the toggle; we don't mutate
    markItemNavigable(ctx, w, id);

    advanceLayout(w, .{ row_w, row_h }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    return clicked;
}

// ============================================================================
// Splitter: implementation
// ============================================================================
// Imgui reference: `SplitterBehavior` at imgui_widgets.cpp:1801-1857.
// Differences from imgui:
//   - Single `*split_pos` arg (caller manages second pane's derived size).
//   - Phone-friendly defaults: 4px visible bar + 16px hit-extend each side.
//   - We render the visible bar inline rather than using imgui's
//     ItemAdd / ButtonBehavior abstraction (zimr's hit-testing is
//     inline in widget impls; matches our existing selectable /
//     button shape).
// Clamping (matching imgui:1832-1844):
//   - mouse_delta = mouse.pos[axis] - active_id_click_offset - bb_interact.min[axis]
//   - delta = clamp(mouse_delta, -(split_pos - min1), (total - split_pos - bar - min2))
//   - *split_pos += delta
// `total_along_axis = 0` means "use the remaining content area along
// this axis from the cursor". Picks up `getContentRegionAvail()`
// behavior implicitly.

// ============================================================================
// MultiSelect: Begin / End / Item hooks implementation
// ============================================================================
// Public entrypoints: `beginMultiSelect`, `endMultiSelect`,
// `setNextItemSelectionUserData`. See type defs near `MultiSelectFlags`
// for the data shapes and `src/notes/multiselect-tutorial.md` for the
// conceptual model.
// (this revision) wires the real click→request emission.
// Implements (subset matching MVP flag coverage):
//   - Begin: keyboard shortcut handling (Escape → set_all(false) if
//     clear_on_escape; Ctrl-A → set_all(true) if !no_select_all),
//     scope-focus detection, loop_request_set_all flag for Header.
//   - End: clear-on-click-void detection (if clear_on_click_void).
//   - Item hooks:
//       - `multiSelectItemHeader` - applies LoopRequestSetAll to
//         `selected` (so items render in their post-shortcut state),
//         maybe alters button-flags (we don't yet - selectable in
//         zimr doesn't use ButtonBehavior abstraction).
//       - `multiSelectItemFooter` - emits SetRange (and sometimes
//         SetAll-clear preceding it) per the click→request truth
//         table on press. Also updates persistent state's
//         RangeSrcItem/RangeSelected.
//   - Selectable integration: header before click detection; footer
//     after.
// Still NOT wired (filed in plan):
//   - Keyboard nav-driven range select (no kb nav in zimr today).
//   - Box-select (drag rectangle subsystem).
//   - Right-click handling (no right-click on phone; low desktop pri).
//   - `is_focused` proper tracking (using window focus as proxy).

// Tear down the previous scope's requests_buf at the start of each
// new beginMultiSelect. Splits out so it's testable in isolation
// and so we can swap to an arena-backed strategy later.

// Helper: emit a SetAll request, dropping prior pending requests
// first (matches imgui's `MultiSelectAddSetAll`). Used by Begin
// keyboard shortcuts and End's click-void detection.

// Helper: emit a SetRange request (does NOT clear prior - caller
// must do that explicitly if needed, matching the imgui truth
// table for click-replace = SetAll(false) + SetRange).
fn addSetRangeRequest(
    ctx: *UiContext,
    scope: *MultiSelectScope,
    first: u64,
    last: u64,
    direction: i2,
    selected: bool,
) void {
    scope.requests_buf.append(ctx.gpa, .{ .set_range = .{
        .first = first,
        .last = last,
        .direction = direction,
        .selected = selected,
    } }) catch {
        @panic("OOM in addSetRangeRequest");
    };
}

// `multiSelectItemHeader`: runs before a widget's click detection
// when a MultiSelect scope is active. In MVP it:
//   - applies LoopRequestSetAll to `selected` (so items render in
//     their post-shortcut state on the SAME frame the shortcut
//     fired, rather than lagging one frame).
// What it doesn't do yet:
//   - keyboard range-select preview (zimr has no kb nav).
//   - button_flags mutation (zimr's selectable doesn't use the
//     ButtonBehavior abstraction; the click logic is inline).
// Returns the (possibly-overridden) selected state. Imgui:
// `imgui_widgets.cpp:8233-8296`.

// `multiSelectItemFooter`: runs after a widget's click detection.
// If `pressed` is true and this item is tagged with
// SetNextItemSelectionUserData, emit the appropriate
// SetAll(false)+SetRange / SetRange-only request pair per
// modifiers. Also updates persistent state's RangeSrcItem and
// RangeSelected.
// Imgui: `imgui_widgets.cpp:8305-8525`. This is the click→request
// truth table from tutorial § 9.
fn multiSelectItemFooter(
    ctx: *UiContext,
    id: Id,
    selected_in: bool,
    pressed: bool,
) void {
    _ = id;
    const scope: *MultiSelectScope = &(ctx.current_multi_select orelse return);

    // Item-data tag from the user. If null, the user forgot to
    // call setNextItemSelectionUserData() before this item - in a
    // properly-constructed scope every item is tagged. Imgui
    // asserts on this; we silently no-op.
    const item_data: u64 = ctx.next_item_selection_user_data orelse return;
    // Consume the slot - next item must tag itself anew.
    ctx.next_item_selection_user_data = null;

    if (!pressed) {
        return;
    }

    // Update is_focused if the user pressed an item - they clearly
    // intended to interact with this scope. Matches imgui:8314.
    scope.is_focused = true;

    const flags: MultiSelectFlags = scope.flags;
    const is_singleselect: bool = (flags.selection == .single);
    const is_ctrl: bool = scope.key_ctrl;
    const is_shift: bool = scope.key_shift and !flags.no_range_select;

    // Imgui: imgui_widgets.cpp:8334-8338. First press of this
    // End-IO turn drops Begin's pending requests (only the
    // click-emitted ones go to the End-time apply).
    if (!scope.is_end_io) {
        scope.requests_buf.clearRetainingCapacity();
        scope.is_end_io = true;
    }

    var selected: bool = selected_in;

    // -------- Should we clear other selections?
    // imgui_widgets.cpp:8459-8470 - translated:
    //   if (!no_auto_clear) {
    //     request_clear = false;
    //     if (single_select) request_clear = true;
    //     else if (mouse && !is_ctrl) {
    //       request_clear = no_auto_clear_on_reselect
    //                       ? !selected // re-tap on selected? leave others
    //                       : true; // tap on new item? wipe others
    //     }
    //     else if (shift && !is_ctrl) request_clear = true;
    //     if (request_clear) emit SetAll(false);
    //   }

    var request_clear: bool = false;
    if (!flags.no_auto_clear) {
        if (is_singleselect) {
            request_clear = true;
        } else if (!is_ctrl) {
            // Mouse press, no Ctrl.
            if (flags.no_auto_clear_on_reselect) {
                request_clear = !selected;
            } else {
                request_clear = true;
            }
        }
        // The `shift && !ctrl` branch is keyboard-only - gated by
        // input_source == keyboard in imgui. zimr has no kb-driven
        // selection yet, so skip.
    }
    if (request_clear) {
        scope.requests_buf.append(ctx.gpa, .{ .set_all = false }) catch {
            @panic("OOM in multiSelectItemFooter (set_all clear)");
        };
    }

    // -------- Compute the SetRange
    // imgui_widgets.cpp:8472-8506. Two branches:
    //   - shift && !single → range from anchor to this item;
    //                          range_selected derived from anchor
    //   - else → single-item range; anchor moves to
    //                          this item; range_selected = ctrl
    //                          ? !selected: true
    var range_first: u64 = item_data;
    var range_last: u64 = item_data;
    var range_direction: i2 = 1;
    var range_selected_value: bool = true;

    if (is_shift and !is_singleselect) {
        // Anchor-to-item range. If no anchor stored yet, this item
        // becomes the anchor.
        if (scope.storage.range_src_item == null) {
            scope.storage.range_src_item = item_data;
            scope.storage.range_selected = selected;
        }
        const anchor: u64 = scope.storage.range_src_item.?;
        range_first = anchor;
        range_last = item_data;
        range_direction = if (anchor <= item_data) 1 else -1;

        if (!flags.no_auto_select) {
            // Shift+Ctrl: copy anchor's selection state (so
            // Ctrl+Shift+Click extends with the anchor's pol).
            // Plain Shift: select=true.
            if (is_ctrl) {
                range_selected_value = scope.storage.range_selected orelse true;
            } else {
                range_selected_value = true;
            }
        } else {
            // NoAutoSelect: shift-click always uses target's
            // toggled state.
            range_selected_value = !selected;
        }
    } else {
        // Single-item range (anchor moves here).
        if (!flags.no_auto_select) {
            selected = if (is_ctrl) !selected else true;
        } else {
            selected = !selected;
        }
        scope.storage.range_src_item = item_data;
        range_selected_value = selected;
        range_first = item_data;
        range_last = item_data;
        range_direction = 1;
    }

    addSetRangeRequest(
        ctx,
        scope,
        range_first,
        range_last,
        range_direction,
        range_selected_value,
    );

    // Persist this item's selected-state if it became the anchor.
    if (scope.storage.range_src_item == item_data) {
        scope.storage.range_selected = selected;
    }
}

// ============================================================================
// Widget: collapsingHeader
// ============================================================================
// Click-to-toggle section header. The header draws as a styled
// horizontal bar with a small triangle indicating open/closed. The
// caller passes a `*bool` they own; we toggle it on click. Returns
// the post-toggle value so the user can gate their child widgets:
//     if (ui.collapsingHeader("Advanced", &state.show_adv)) {... }

// ============================================================================
// Widget: bullet (small filled circle as a same-line glyph)
// ============================================================================

// ============================================================================
// Widget: image - display a GPU texture inline
// ============================================================================
// Uses rlgl's textured-quad path: bind texture, emit two triangles
// with UV coords (0,0)-(1,1). No tint or border options yet
// caller decorates with surrounding widgets if needed.

// ============================================================================
// Widget: inputText (single-line)
// ============================================================================
// State: per-active-input cursor + blink timer on UiContext.
// Input: chars_typed[] for printable ASCII; key_* one-shots for
//         backspace / delete / arrows / home / end / enter / escape.
// Memory: caller owns the backing buffer + length pointer; we never
//         realloc. Insertion past `buf.len` is silently clamped.
// What's NOT here yet: selection ranges, clipboard, IME, multi-line,
// mouse-drag selection, undo/redo, password masking, validation.
// All planned for later phases of inputText work.

// ============================================================================
// inputText callbacks - A4 dispatch helpers
// ============================================================================
// Wired into both `inputTextImpl` and `inputTextMultilineImpl`.
// CharFilter is the fast path (per-character); the others fire on
// specific events (Tab, Up/Down, end-of-edit-frame).

/// Build an `InputTextCallbackData` view of the current widget's
/// state and call `cb`. Helper to avoid 8-line boilerplate at
/// every callback site. `data.event` and `data.event_char` /
/// `history_dir` are set per the call.
fn runInputCallback(
    event: InputTextEvent,
    cb: InputTextCallback,
    user_data: ?*anyopaque,
    buf: []u8,
    buf_len: *usize,
    cursor_pos: *usize,
    event_char: u32,
    history_dir: HistoryDir,
) void {
    var data: InputTextCallbackData = .{
        .event = event,
        .buf = buf,
        .buf_len = buf_len,
        .cursor_pos = cursor_pos,
        .event_char = event_char,
        .history_dir = history_dir,
        .user_data = user_data,
    };
    cb(&data);
}

// Show the DOM overlay input at the widget's screen rect, populated
// with the buffer's current text. The overlay becomes the user-
// visible editor while focused - native browser handles keyboard,
// backspace, IME, selection, scroll-into-view. Each frame, ui.zig
// polls `getOverlayInputText` to sync the DOM value back into the
// buffer.
// All coords are CSS pixels in the canvas's coord space (same as
// mouse_pos). Colors are RGBA u32 packed identically to zimr's
// internal `colorToU32`. On host build, no-op.
//
// After showing, applies P9.2 behavior flags to the overlay
// element via dedicated JS calls (each is a small idempotent
// property write).  The order matters: `show_overlay_input`
// creates/refreshes the element first, then attribute writes can
// take effect.
fn showOverlayInput(
    box: Rectangle,
    text: []const u8,
    font_px: f32,
    fg_rgba: u32,
    bg_rgba: u32,
    opts: InputTextOpts,
) void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    const dom = @import("web.zig").dom;
    dom.show_overlay_input(
        box.x,
        box.y,
        box.width,
        box.height,
        text,
        font_px,
        fg_rgba,
        bg_rgba,
    );
    // Attribute writes: each unconditionally pushes the current
    // flag value through, so flipping a flag back to false at the
    // next focus cycle correctly resets the property.  No "only
    // when true" gating — that would leave stale state from a
    // prior focus.
    dom.set_input_mode(opts.input_mode.toAttr());
    dom.set_overlay_input_password(opts.password_mask);
    dom.set_overlay_input_read_only(opts.read_only);
    dom.set_overlay_input_escape_clears(opts.escape_clears);
    dom.set_overlay_input_allow_tab(opts.allow_tab_input);
    dom.set_overlay_input_char_filters(packCharFilterFlags(opts));
}

fn hideOverlayInput() void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    @import("web.zig").dom.hide_overlay_input();
}

// Reposition the DOM overlay to a new rect without re-focusing /
// re-seeding cursor. Called per frame while a text widget is
// active so the overlay tracks window drags, scrolls, and viewport
// resizes. No-op on host build.
fn updateOverlayInputRect(box: Rectangle) void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    @import("web.zig").dom.update_overlay_input_rect(box.x, box.y, box.width, box.height);
}

// Intersect a widget box with its parent window's content-clip rect
// (the area below the title bar, inside the window border - same
// clip rect pushed in `windowImpl`). Used to keep the DOM overlay
// from rendering outside the window: `position: fixed` ignores
// wasm-side scissor, so a widget that imgui draws clipped (because
// the user scrolled it off-window) would otherwise have its overlay
// drawn floating in mid-air.
// Returns null if the widget is fully clipped (no visible portion).
// DELIBERATELY does NOT clip against canvas bounds. Earlier (turn
// 297) we did - to catch the case of a window dragged so its bottom
// extends past the canvas. But the visualViewport canvas-shrink
// listener (in `attachInputHandlers`) temporarily reduces the
// canvas's height when the soft keyboard pops; with canvas-clip
// active, the widget the user just activated would land outside
// `canvas_h` for a few frames and the overlay would auto-hide,
// which blurs the input and dismisses the keyboard immediately.
// User report: "keyboard appears but disappears
// immediately." Confirmed via the debug HUD - widget at y=418 was
// inside canvas_h=861 at show time, but the shrink moved canvas_h
// below 418 within one frame.
// If the window-dragged-off-canvas case re-emerges (it was the bug
//'s image 1, may or may not still reproduce), revisit
// with a different strategy: clip against
// `pre-keyboard-canvas-height` instead of the live shrunken value.
fn clipWidgetBoxToWindow(
    ctx: *const UiContext,
    w: *const Window,
    box: Rectangle,
) ?Rectangle {
    const clip_x: f32 = w.pos[0];
    const clip_y: f32 = w.pos[1] + ctx.style.title_bar_height;
    const clip_x2: f32 = w.pos[0] + w.size[0];
    const clip_y2: f32 = w.pos[1] + w.size[1];

    const x1: f32 = @max(box.x, clip_x);
    const y1: f32 = @max(box.y, clip_y);
    const x2: f32 = @min(box.x + box.width, clip_x2);
    const y2: f32 = @min(box.y + box.height, clip_y2);

    if (x2 <= x1 or y2 <= y1) {
        return null;
    }
    return .{ .x = x1, .y = y1, .width = x2 - x1, .height = y2 - y1 };
}

// True if the DOM overlay input is currently shown. Used to
// detect user-initiated blur (tap outside the input area, Enter
// on a single-line input both fire blur JS-side, which hides the
// overlay). Wasm polls each frame and mirrors `active_id = 0`
// so the widget's edit state matches the DOM's. On host build,
// always false.
fn overlayInputIsVisible() bool {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return false;
    }
    return @import("web.zig").dom.overlay_input_is_visible();
}

// Returns the byte length written into `out`. On host build,
// returns 0 (no overlay exists; wasm-side typing path still runs).
fn getOverlayInputText(out: []u8) usize {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return 0;
    }
    return @import("web.zig").dom.get_overlay_input_text(out);
}

// ============================================================================
// Multiline `<textarea>` overlay wrappers (turn 441b)
// ============================================================================
// Sibling of the `<input>` wrappers above.  Each fn comptime-
// guards on wasm target and no-ops on host.  Used by
// `inputTextMultilineImpl` to delegate editing to the browser
// on web, mirroring what `inputTextImpl` already does for
// single-line.

fn showOverlayTextarea(
    box: Rectangle,
    text: []const u8,
    font_px: f32,
    fg_rgba: u32,
    bg_rgba: u32,
    opts: InputTextOpts,
) void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    const dom = @import("web.zig").dom;
    dom.show_overlay_textarea(
        box.x,
        box.y,
        box.width,
        box.height,
        text,
        font_px,
        fg_rgba,
        bg_rgba,
    );
    // Attribute writes.  No password_mask (N/A for textareas).
    // No `set_input_mode` either — input_mode is single-line
    // soft-keyboard hinting; multiline use cases don't benefit
    // from numeric/email keyboards (typical multiline content
    // is prose).
    dom.set_overlay_textarea_read_only(opts.read_only);
    dom.set_overlay_textarea_escape_clears(opts.escape_clears);
    dom.set_overlay_textarea_allow_tab(opts.allow_tab_input);
    dom.set_overlay_textarea_char_filters(packCharFilterFlags(opts));
    dom.set_overlay_textarea_ctrl_enter_for_newline(opts.ctrl_enter_for_newline);
}

fn hideOverlayTextarea() void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    @import("web.zig").dom.hide_overlay_textarea();
}

fn updateOverlayTextareaRect(box: Rectangle) void {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return;
    }
    @import("web.zig").dom.update_overlay_textarea_rect(box.x, box.y, box.width, box.height);
}

fn overlayTextareaIsVisible() bool {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return false;
    }
    return @import("web.zig").dom.overlay_textarea_is_visible();
}

fn getOverlayTextareaText(out: []u8) usize {
    if (comptime !builtin.target.cpu.arch.isWasm()) {
        return 0;
    }
    return @import("web.zig").dom.get_overlay_textarea_text(out);
}

fn cursorIndexFromX(
    ctx: *const UiContext,
    s: []const u8,
    local_x: f32,
) usize {
    if (s.len == 0 or local_x <= 0) {
        return 0;
    }
    // Walk byte-by-byte (is ASCII). For UTF-8 we'd advance
    // by codepoint length and measure the prefix appropriately.
    var i: usize = 1;
    var prev_w: f32 = 0;
    while (i <= s.len) : (i += 1) {
        const w = measureTextS(ctx, s[0..i])[0];
        const midpoint = (prev_w + w) / 2;
        if (local_x < midpoint) return i - 1;
        prev_w = w;
    }
    return s.len;
}

fn inputTextImpl(
    ctx: *UiContext,
    label: []const u8,
    buf: []u8,
    len: *usize,
    opts: InputTextOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const text_size: Vec2 = measureTextS(ctx, label);
    const box_h: f32 = ctx.style.font_size + 2 * padding[1];
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];

    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);
    const reserve_label: f32 = text_size[0] + ctx.style.item_spacing[0];
    const box_w: f32 = effectiveItemWidth(
        ctx,
        opts.width,
        @max(60, inner_right - at[0] - reserve_label),
    );

    const box = Rectangle{ .x = at[0], .y = at[1], .width = box_w, .height = box_h };
    const hovered: bool = pointInRect(ctx.input.mouse_pos, box);
    if (hovered) {
        ctx.hovered_id = id;
    }

    var changed: bool = false;
    // True on the frame the user "committed" the field's content
    // while it was focused.  Used by `opts.enter_returns_true` to
    // decide the function's return value.  Host path: literal
    // Enter detection.  Web path: detected as "overlay input
    // went from visible to hidden this frame," which catches
    // Enter on the mobile soft keyboard's "Done" key (the most
    // common commit gesture on mobile) and Enter on desktop
    // browsers.  Esc + tap-outside also fire that transition; on
    // web the caller disambiguates via the buffer state
    // (`escape_clears` zeros len, giving the caller a signal).
    var commit_pressed: bool = false;
    // Snapshot focus state BEFORE the focus-management branches
    // below mutate it.  Used by the web path to detect
    // "widget was focused at frame start and isn't anymore" as
    // a commit signal.
    const was_focused_at_start: bool = ctx.active_id == id;

    // Honor `setKeyboardFocusHere` - if it was called this frame
    // and we're the next focusable widget, take focus.
    if (ctx.next_widget_take_focus) {
        ctx.next_widget_take_focus = false;
        const was_already_focused: bool = ctx.active_id == id;
        ctx.active_id = id;
        ctx.active_id_just_activated = !was_already_focused;
        if (!was_already_focused) {
            ctx.input_text_state = .{};
            ctx.input_text_state.cursor_pos = len.*;
            // Only show overlay if widget is at least partially
            // visible inside its window's content area. See
            // `clipWidgetBoxToWindow` for the rationale.
            if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                showOverlayInput(
                    visible_box,
                    buf[0..len.*],
                    ctx.style.font_size,
                    Color.toWire(ctx.style.text),
                    Color.toWire(ctx.style.frame_bg_active),
                    opts,
                );
            }
        }
    }

    // ---- Focus management ---------------------------------------
    // Click inside the box → take focus and seed cursor at click X.
    // Click outside while focused → release focus.
    if (ctx.input.mouse_left_clicked) {
        if (hovered) {
            // Gain focus. Reset edit state.
            const was_already_focused: bool = ctx.active_id == id;
            ctx.active_id = id;
            ctx.active_id_just_activated = !was_already_focused;
            if (!was_already_focused) {
                ctx.input_text_state = .{};
                // Default cursor to end of buffer; better UX than 0.
                ctx.input_text_state.cursor_pos = len.*;
                // Show the visible DOM overlay input at the widget's
                // screen rect. While focused, the DOM input is the
                // editor - native browser handles keyboard, IME,
                // backspace, selection, scroll-into-view above the
                // soft keyboard. Each frame below we poll the DOM
                // value back into `buf`. See `web.zig` for the
                // architecture rationale. No-op on host
                // build (native desktop / tests).
                // Only show if widget is at least partially visible
                // inside its window's content clip. See
                // `clipWidgetBoxToWindow`.
                if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                    showOverlayInput(
                        visible_box,
                        buf[0..len.*],
                        ctx.style.font_size,
                        Color.toWire(ctx.style.text),
                        Color.toWire(ctx.style.frame_bg_active),
                        opts,
                    );
                }
            }
            // Position cursor at click X.
            const click_local_x: f32 = ctx.input.mouse_pos[0] - (box.x + padding[0]);
            ctx.input_text_state.cursor_pos = cursorIndexFromX(ctx, buf[0..len.*], click_local_x);
        } else if (ctx.active_id == id) {
            // Click outside while focused → defocus. No revert.
            ctx.active_id = 0;
            hideOverlayInput();
        }
    }

    // ---- Edit operations (only while focused) -------------------------------
    if (ctx.active_id == id) {
        // Web path: the DOM overlay input is the editor. Poll its
        // current value each frame and sync into the wasm-side
        // buffer. Also detect user-initiated blur (tap outside,
        // Enter on single-line) by checking visibility and mirror
        // `active_id = 0`.
        // Native (host) path: read keystrokes from
        // `ctx.input.chars_typed` and the key_* flags directly.
        // This is the legacy path; used by tests and any future
        // native desktop demos.
        const on_web: bool = comptime builtin.target.cpu.arch.isWasm();
        if (on_web) {
            // Track widget visibility every frame so the overlay
            // follows window drags / scrolls / viewport resizes, and
            // hides when the widget would render outside its window's
            // content area. Without this, the DOM overlay (which is
            // `position: fixed` and ignores wasm-side scissor) can
            // appear floating in mid-air when the user drags the
            // parent window or scrolls the widget off-content.
            // (Bug found - the user's Chrome-desktop-mode test.)
            if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                updateOverlayInputRect(visible_box);
            } else {
                hideOverlayInput();
            }

            // Poll DOM input value back into `buf`. Truncate to
            // capacity if longer. Tracks `changed` for the edit
            // callback below.
            const new_len: usize = getOverlayInputText(buf);
            if (new_len != len.*) {
                // Length differs → content definitely changed.
                len.* = new_len;
                changed = true;
            } else if (new_len > 0) {
                // Same length, possibly different bytes. This is
                // the edge case (e.g. user replaced one char with
                // another via IME). Cheap byte compare to detect.
                // The DOM-poll wrote bytes into buf already; the
                // question is whether we should signal `changed`.
                // For simplicity, only signal on length change for
                // now - content-only changes still propagate via the
                // buffer mutation, just don't fire the edit callback
                // an extra time. Refine if a demo needs it.
            }
            // Keep wasm-side cursor at end so any logic that reads
            // it (e.g. setKeyboardFocusHere re-entering) doesn't
            // see stale state. Real cursor position lives in the
            // DOM input while overlay is shown.
            ctx.input_text_state.cursor_pos = len.*;

            // Detect user-initiated blur from JS side. If overlay
            // is no longer visible, the user tapped away or pressed
            // Enter - release focus.
            if (!overlayInputIsVisible()) {
                ctx.active_id = 0;
            }
        } else {
            // ---- Native edit-ops path
            // Insert printable characters typed this frame. Each char
            // optionally goes through `opts.char_filter` which can drop
            // it (set event_char = 0) or substitute it.  Gated on
            // `!read_only` — a read-only field still keeps focus + caret
            // navigation but rejects every mutation source.
            var ci: usize = 0;
            while (!opts.read_only and ci < ctx.input.chars_typed_count) : (ci += 1) {
                const original_cp: u32 = ctx.input.chars_typed[ci];
                const cp: u32 = runCharFilter(opts, buf, len, &ctx.input_text_state.cursor_pos, original_cp);
                // ASCII printable only for now. UTF-8 multibyte is its
                // own arc - needs codepoint width measurement + byte-
                // count cursor advance.
                const is_printable: bool = cp >= 0x20 and cp < 0x7F;
                const has_room: bool = len.* < buf.len;
                if (is_printable and has_room) {
                    const ins_at: usize = ctx.input_text_state.cursor_pos;
                    std.mem.copyBackwards(u8, buf[ins_at + 1 .. len.* + 1], buf[ins_at..len.*]);
                    buf[ins_at] = @intCast(cp);
                    len.* += 1;
                    ctx.input_text_state.cursor_pos += 1;
                    changed = true;
                }
            }

            // Backspace: delete byte before cursor.  Gated on !read_only.
            const can_backspace: bool = !opts.read_only and
                ctxKeyPressedOrRepeat(ctx, .backspace) and
                ctx.input_text_state.cursor_pos > 0;
            if (can_backspace) {
                const cp: usize = ctx.input_text_state.cursor_pos;
                std.mem.copyForwards(u8, buf[cp - 1 .. len.* - 1], buf[cp..len.*]);
                len.* -= 1;
                ctx.input_text_state.cursor_pos -= 1;
                changed = true;
            }

            // Delete: delete byte at cursor (forward delete).  Gated on
            // !read_only.
            const can_delete: bool = !opts.read_only and
                ctxKeyPressedOrRepeat(ctx, .delete) and
                ctx.input_text_state.cursor_pos < len.*;
            if (can_delete) {
                const cp: usize = ctx.input_text_state.cursor_pos;
                std.mem.copyForwards(u8, buf[cp .. len.* - 1], buf[cp + 1 .. len.*]);
                len.* -= 1;
                changed = true;
            }

            // Arrow navigation.  NOT gated on read_only — caret still
            // moves so the user can position selection (when selection
            // lands).
            if (ctxKeyPressedOrRepeat(ctx, .left) and ctx.input_text_state.cursor_pos > 0) {
                ctx.input_text_state.cursor_pos -= 1;
                ctx.input_text_state.blink_t = 0; // re-show cursor on move
            }
            if (ctxKeyPressedOrRepeat(ctx, .right) and ctx.input_text_state.cursor_pos < len.*) {
                ctx.input_text_state.cursor_pos += 1;
                ctx.input_text_state.blink_t = 0;
            }
            if (ctx.input.keys[@intFromEnum(KeyCode.home)].pressed_this_frame) {
                ctx.input_text_state.cursor_pos = 0;
                ctx.input_text_state.blink_t = 0;
            }
            if (ctx.input.keys[@intFromEnum(KeyCode.end)].pressed_this_frame) {
                ctx.input_text_state.cursor_pos = len.*;
                ctx.input_text_state.blink_t = 0;
            }

            // Up / Down (single-line only) → History callback. Caller
            // typically rewrites the buffer with the prior / next
            // history entry. `changed` becomes true since the buffer
            // shifted; the `edit` callback (if any) fires below.  Gated
            // on !read_only — a read-only field isn't editable.
            // Up / Down (single-line only) → History callback. Caller
            // typically rewrites the buffer with the prior / next
            // history entry. `changed` becomes true since the buffer
            // shifted; the `edit` callback (if any) fires below.  Gated
            // on !read_only — a read-only field isn't editable.
            if (!opts.read_only) {
                if (opts.history) |hist_cb| {
                    if (ctxKeyPressedOrRepeat(ctx, .up)) {
                        runInputCallback(
                            .history,
                            hist_cb,
                            opts.user_data,
                            buf,
                            len,
                            &ctx.input_text_state.cursor_pos,
                            0,
                            .up,
                        );
                        changed = true;
                    }
                    if (ctxKeyPressedOrRepeat(ctx, .down)) {
                        runInputCallback(
                            .history,
                            hist_cb,
                            opts.user_data,
                            buf,
                            len,
                            &ctx.input_text_state.cursor_pos,
                            0,
                            .down,
                        );
                        changed = true;
                    }
                }
            }

            // Tab dispatch.  Three regimes:
            //   1. `allow_tab_input` set → insert '\t' into the buffer
            //      (overrides completion).  Gated on !read_only.
            //   2. `completion` callback set → fire it.
            //   3. neither → no-op (browser-tab focus advance handles
            //      itself on web; host-only Tab is just dropped).
            const tab_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.tab)].pressed_this_frame;
            if (tab_pressed) {
                if (opts.allow_tab_input and !opts.read_only) {
                    const has_room: bool = len.* < buf.len;
                    if (has_room) {
                        const ins_at: usize = ctx.input_text_state.cursor_pos;
                        std.mem.copyBackwards(u8, buf[ins_at + 1 .. len.* + 1], buf[ins_at..len.*]);
                        buf[ins_at] = '\t';
                        len.* += 1;
                        ctx.input_text_state.cursor_pos += 1;
                        changed = true;
                    }
                } else if (opts.completion) |comp_cb| {
                    runInputCallback(
                        .completion,
                        comp_cb,
                        opts.user_data,
                        buf,
                        len,
                        &ctx.input_text_state.cursor_pos,
                        0,
                        .up,
                    );
                    changed = true;
                }
            }
        }

        // Edit callback fires once at end of all edits for this
        // frame, after the buffer has settled.
        if (changed) {
            if (opts.edit) |edit_cb| {
                runInputCallback(.edit, edit_cb, opts.user_data, buf, len, &ctx.input_text_state.cursor_pos, 0, .up);
            }
        }

        // Native Enter/Esc commits + defocuses. On web this path
        // doesn't fire (DOM input swallows the keystroke; JS side
        // calls hide on Enter, which is detected via the
        // `overlayInputIsVisible` check above). Advance the blink
        // timer regardless (only matters on host where wasm renders
        // the cursor).
        const enter_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame;
        const escape_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame;
        if (!on_web) {
            if (enter_pressed) {
                // Enter is a commit on host.  Defocus + flag
                // commit_pressed so `enter_returns_true` reports it.
                commit_pressed = true;
                ctx.active_id = 0;
                hideOverlayInput();
            } else if (escape_pressed) {
                // Esc defocuses regardless.  With escape_clears, zero
                // the buffer first so callers polling len see the
                // cancel.  Esc is NOT a commit — commit_pressed stays
                // false so enter_returns_true distinguishes
                // commit-from-Enter vs cancel-from-Esc on host.
                if (opts.escape_clears) {
                    len.* = 0;
                    ctx.input_text_state.cursor_pos = 0;
                    changed = true;
                }
                ctx.active_id = 0;
                hideOverlayInput();
            }
        } else {
            // Web path: the DOM input handled Enter/Esc and blurred
            // itself.  The overlay-visibility poll at the top of the
            // focused-branch above already cleared active_id.  Detect
            // commit here as "we were focused at frame start AND
            // active_id is no longer us" — this catches Enter AND
            // Esc AND tap-outside.  Caller with escape_clears can
            // disambiguate via buf len.  Without escape_clears, the
            // web granularity is "focus lost"; refining requires
            // JS-side last-blur-key tracking (deferred).
            if (was_focused_at_start and ctx.active_id != id) {
                commit_pressed = true;
            }
        }

        // Advance the blink timer (host-only cursor blink).
        ctx.input_text_state.blink_t += ctx.input.delta_time;
    }

    // ---- Render
    const bg: Color = if (ctx.active_id == id)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, box, Color.toWire(bg));

    // Draw current content. When the buffer is empty AND the
    // field isn't focused AND a hint was provided, render the hint
    // in disabled text color as a placeholder.
    // Web: when ACTIVE, suppress wasm-side text + cursor rendering
    // - the DOM overlay input draws those over the canvas instead.
    // Without suppression we'd see both the wasm-drawn text AND the
    // DOM input's text rendered on top of each other.
    // Password masking lives on the host render path: when set,
    // every buffer byte renders as `*`.  On web the DOM input owns
    // the masking (browser does `type="password"`), so this branch
    // is a no-op there.
    const on_web_render: bool = comptime builtin.target.cpu.arch.isWasm();
    const overlay_active: bool = on_web_render and ctx.active_id == id;
    const content_x: f32 = box.x + padding[0];
    const content_y: f32 = box.y + padding[1];
    var mask_buf: [256]u8 = undefined;
    const display_text: []const u8 = passwordMaskText(opts, buf[0..len.*], &mask_buf);
    if (!overlay_active) {
        if (len.* == 0 and ctx.active_id != id and opts.hint.len > 0) {
            drawTextAtS(ctx, .{ content_x, content_y }, opts.hint, Color.toWire(ctx.style.text_disabled));
        } else {
            drawTextAtS(ctx, .{ content_x, content_y }, display_text, Color.toWire(ctx.style.text));
        }
    }

    // Draw cursor when active. Blink: visible 0.0-0.5s, hidden 0.5-1.0s.
    // Suppressed on web while overlay is active (DOM cursor shows instead).
    if (ctx.active_id == id and !overlay_active) {
        const phase: f32 = @mod(ctx.input_text_state.blink_t, 1.0);
        if (phase < 0.5) {
            // Use the masked display text for cursor positioning so
            // the caret lines up with what the user sees on a
            // password field.  Slice ends at the display equivalent
            // of cursor_pos (one-to-one for ASCII; the password
            // mask emits one `*` per byte so the byte index works
            // directly).
            const before: []const u8 = display_text[0..@min(ctx.input_text_state.cursor_pos, display_text.len)];
            const cursor_x: f32 = content_x + measureTextS(ctx, before)[0];
            drawRectFilled(
                ctx,
                .{ .x = cursor_x, .y = content_y, .width = 1, .height = ctx.style.font_size },
                Color.toWire(ctx.style.text),
            );
        }
    }

    // Draw label to the right of the box.
    drawTextAtS(
        ctx,
        .{ box.x + box.width + ctx.style.item_spacing[0], content_y },
        label,
        Color.toWire(ctx.style.text),
    );

    const total_w: f32 = box.width + ctx.style.item_spacing[0] + text_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = box.x, .y = box.y, .width = total_w, .height = box.height };
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, changed);

    advanceLayout(w, .{ total_w, box.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    // Return value depends on `enter_returns_true`:
    //   - default (false): `changed` — true on every frame the
    //     buffer mutated.  Matches imgui's default and existing
    //     zimr behavior.
    //   - true: report only "commit" events.  Host: literal
    //     Enter while focused.  Web: focus dropped this frame
    //     (Enter or Esc on the soft keyboard, tap-outside).
    //     Esc with `escape_clears` is observable to the caller
    //     via `len.* == 0` after the call.
    if (opts.enter_returns_true) {
        return commit_pressed;
    }
    return changed;
}

// ============================================================================
// inputTextMultiline - Phase A3
// ============================================================================
// Same buffer/cursor model as `inputTextImpl`: cursor_pos is a
// byte offset. A newline is just a byte (0x0A) inside the buffer.
// The 2D-ness lives in two places: render (drawTextAtS handles \n
// natively) and Up/Down navigation (which convert byte_pos to
// (line, col), shift line, convert back).

/// (line, column) view of a buffer position. `line` is 0-based
/// line number, `col` is byte offset within the line.
const LineCol = struct {
    line: usize,
    col: usize,
};

/// Convert byte offset → (line, col).
fn byteToLineCol(buf: []const u8, byte_pos: usize) LineCol {
    var line: usize = 0;
    var line_start: usize = 0;
    var i: usize = 0;
    while (i < byte_pos and i < buf.len) : (i += 1) {
        if (buf[i] == '\n') {
            line += 1;
            line_start = i + 1;
        }
    }
    return .{ .line = line, .col = byte_pos - line_start };
}

/// Convert (line, target_col_x_pixels) → byte offset. Used by
/// click-to-place. `target_col_x` is the click x relative to the
/// editor's content-left. Clamps gracefully - a click past the
/// end of the buffer lands at `buf.len`.
fn xyToByte(
    ctx: *const UiContext,
    buf: []const u8,
    target_line: usize,
    target_col_x: f32,
) usize {
    // Walk lines until we hit `target_line` or the end.
    var line: usize = 0;
    var line_start: usize = 0;
    var i: usize = 0;
    while (i < buf.len and line < target_line) : (i += 1) {
        if (buf[i] == '\n') {
            line += 1;
            line_start = i + 1;
        }
    }
    if (line < target_line) {
        // Click was below the last line - clamp to buf end.
        return buf.len;
    }
    // Find end-of-line.
    var line_end: usize = line_start;
    while (line_end < buf.len and buf[line_end] != '\n') : (line_end += 1) {}
    // Find best column for target_col_x within this line's slice.
    const line_text: []const u8 = buf[line_start..line_end];
    const col: usize = cursorIndexFromX(ctx, line_text, target_col_x);
    return line_start + col;
}

/// Convert (line, col) → byte offset, clamping col to the target
/// line's length. Used by Up/Down arrow nav: preserve the column
/// as best the new line allows.
fn lineColToByte(buf: []const u8, lc: LineCol) usize {
    var line: usize = 0;
    var line_start: usize = 0;
    var i: usize = 0;
    while (i < buf.len and line < lc.line) : (i += 1) {
        if (buf[i] == '\n') {
            line += 1;
            line_start = i + 1;
        }
    }
    if (line < lc.line) {
        return buf.len;
    }
    // Find end-of-line.
    var line_end: usize = line_start;
    while (line_end < buf.len and buf[line_end] != '\n') : (line_end += 1) {}
    const line_len: usize = line_end - line_start;
    const clamped_col: usize = @min(lc.col, line_len);
    return line_start + clamped_col;
}

fn inputTextMultilineImpl(
    ctx: *UiContext,
    label: []const u8,
    buf: []u8,
    len: *usize,
    size: Vec2,
    opts: InputTextOpts,
) bool {
    const w: *Window = ctx.current_window orelse return false;
    const id: Id = widgetId(ctx, w, label);

    const padding: Vec2 = ctx.style.frame_padding;
    const line_h: f32 = ctx.style.font_size + float(ctx.style.line_spacing);
    const inner_right: f32 = w.pos[0] + w.size[0] - ctx.style.window_padding[0];
    const at: Vec2 = resolveCursor(w, ctx.style.item_spacing[0]);

    var actual_w: f32 = if (size[0] > 0) size[0] else (inner_right - at[0]);
    if (actual_w < 80) {
        actual_w = 80;
    }
    var actual_h: f32 = if (size[1] > 0) size[1] else (line_h * 4 + 2 * padding[1]);
    if (actual_h < line_h + 2 * padding[1]) {
        actual_h = line_h + 2 * padding[1];
    }

    const box: Rectangle = .{ .x = at[0], .y = at[1], .width = actual_w, .height = actual_h };
    const hovered: bool = pointInRect(ctx.input.mouse_pos, box);
    if (hovered) {
        ctx.hovered_id = id;
    }

    var changed: bool = false;
    // P9.2 tracking — mirrors `inputTextImpl`.  `commit_pressed`
    // becomes true when the user issued an Enter-as-commit (host
    // path) or focus dropped while widget was active (web path).
    // Used to honor `enter_returns_true` at function return.
    var commit_pressed: bool = false;
    const was_focused_at_start: bool = ctx.active_id == id;

    // ---- Focus management
    if (ctx.next_widget_take_focus) {
        ctx.next_widget_take_focus = false;
        const was_already_focused: bool = ctx.active_id == id;
        ctx.active_id = id;
        ctx.active_id_just_activated = !was_already_focused;
        if (!was_already_focused) {
            ctx.input_text_state = .{};
            ctx.input_text_state.cursor_pos = len.*;
            if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                showOverlayTextarea(
                    visible_box,
                    buf[0..len.*],
                    ctx.style.font_size,
                    Color.toWire(ctx.style.text),
                    Color.toWire(ctx.style.frame_bg_active),
                    opts,
                );
            }
        }
    }

    if (ctx.input.mouse_left_clicked) {
        if (hovered) {
            const was_already_focused: bool = ctx.active_id == id;
            ctx.active_id = id;
            ctx.active_id_just_activated = !was_already_focused;
            if (!was_already_focused) {
                ctx.input_text_state = .{};
                ctx.input_text_state.cursor_pos = len.*;
                if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                    showOverlayTextarea(
                        visible_box,
                        buf[0..len.*],
                        ctx.style.font_size,
                        Color.toWire(ctx.style.text),
                        Color.toWire(ctx.style.frame_bg_active),
                        opts,
                    );
                }
            }
            // Compute (line, col) from click position.
            const local_x: f32 = ctx.input.mouse_pos[0] - (box.x + padding[0]);
            const local_y: f32 = ctx.input.mouse_pos[1] - (box.y + padding[1]);
            const clamped_y: f32 = @max(0, local_y);
            const clicked_line: usize = @floor(clamped_y / line_h);
            ctx.input_text_state.cursor_pos = xyToByte(ctx, buf[0..len.*], clicked_line, local_x);
        } else if (ctx.active_id == id) {
            ctx.active_id = 0;
            hideOverlayTextarea();
        }
    }

    // ---- Edit operations (focused only) -------------------------
    if (ctx.active_id == id) {
        // Web path: the DOM textarea overlay is the editor.  Poll
        // its value back each frame; the keydown listener handles
        // Enter / Esc / Tab semantics already (commit-on-Enter,
        // clear-on-Esc, insert-\t-on-Tab) based on the attribute
        // setters pushed at show time.
        //
        // Host path: run the wasm-side edit loop (chars_typed,
        // key state, the original behavior).
        const on_web: bool = comptime builtin.target.cpu.arch.isWasm();
        if (on_web) {
            // Track visibility so the overlay follows window
            // drags / scrolls / viewport resize, matching the
            // single-line behavior in `inputTextImpl`.
            if (clipWidgetBoxToWindow(ctx, w, box)) |visible_box| {
                updateOverlayTextareaRect(visible_box);
            } else {
                hideOverlayTextarea();
            }

            // Poll DOM value back into `buf`.  Truncate to
            // capacity if longer.  Length-change signals `changed`.
            const new_len: usize = getOverlayTextareaText(buf);
            if (new_len != len.*) {
                len.* = new_len;
                changed = true;
            }
            // Same caveat as single-line: keep wasm-side cursor
            // at end so re-entry logic doesn't see stale state.
            // The real cursor lives in the DOM textarea while
            // the overlay is shown.
            ctx.input_text_state.cursor_pos = len.*;

            // Detect JS-side blur (Enter-commit, tap-outside,
            // Esc-with-or-without-clear).  Mirror to active_id = 0.
            if (!overlayTextareaIsVisible()) {
                ctx.active_id = 0;
            }
        } else {
            // ---- Host edit-ops path (original logic, unchanged)
            // Insert printable characters. Each runs through
            // `opts.char_filter` first (if any) so multiline shares
            // the same filter pipeline as single-line.  Gated on
            // `!read_only` — a read-only multiline still keeps focus +
            // caret navigation but rejects all mutation sources.
            var ci: usize = 0;
            while (!opts.read_only and ci < ctx.input.chars_typed_count) : (ci += 1) {
                const original_cp: u32 = ctx.input.chars_typed[ci];
                const cp: u32 = runCharFilter(opts, buf, len, &ctx.input_text_state.cursor_pos, original_cp);
                const is_printable: bool = cp >= 0x20 and cp < 0x7F;
                const has_room: bool = len.* < buf.len;
                if (is_printable and has_room) {
                    const ins_at: usize = ctx.input_text_state.cursor_pos;
                    std.mem.copyBackwards(u8, buf[ins_at + 1 .. len.* + 1], buf[ins_at..len.*]);
                    buf[ins_at] = @intCast(cp);
                    len.* += 1;
                    ctx.input_text_state.cursor_pos += 1;
                    changed = true;
                }
            }

            // Enter dispatch.  Four regimes based on
            // `ctrl_enter_for_newline` + Ctrl modifier:
            //   - flag off, plain Enter   → insert `\n`
            //   - flag off, Ctrl+Enter    → also insert `\n` (Ctrl is
            //                                ignored when the flag is
            //                                off so users with sticky
            //                                Ctrl don't lose newlines)
            //   - flag on,  plain Enter   → commit + defocus
            //   - flag on,  Ctrl+Enter    → insert `\n`
            // Char insertion is gated on `!read_only`; commit branch
            // runs regardless (a read-only multiline can still be
            // "submitted" via Enter for accessibility navigation).
            const enter_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame;
            if (enter_pressed) {
                const ctrl_down: bool = snapshotCtrlDown(&ctx.input);
                const should_commit: bool = opts.ctrl_enter_for_newline and !ctrl_down;
                const should_insert: bool = !should_commit and !opts.read_only and len.* < buf.len;
                if (should_commit) {
                    commit_pressed = true;
                    ctx.active_id = 0;
                    hideOverlayTextarea();
                } else if (should_insert) {
                    const ins_at: usize = ctx.input_text_state.cursor_pos;
                    std.mem.copyBackwards(u8, buf[ins_at + 1 .. len.* + 1], buf[ins_at..len.*]);
                    buf[ins_at] = '\n';
                    len.* += 1;
                    ctx.input_text_state.cursor_pos += 1;
                    changed = true;
                }
            }

            // Escape: like single-line, with `escape_clears` zeroing
            // the buffer + cursor before defocus.  Esc is NEVER a
            // commit on host — caller distinguishes commit-vs-cancel
            // via `commit_pressed`.
            const escape_pressed: bool = ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame;
            if (escape_pressed) {
                if (opts.escape_clears) {
                    len.* = 0;
                    ctx.input_text_state.cursor_pos = 0;
                    changed = true;
                }
                ctx.active_id = 0;
                hideOverlayTextarea();
            }

            // Backspace.  Gated on !read_only.
            const can_backspace: bool = !opts.read_only and
                ctxKeyPressedOrRepeat(ctx, .backspace) and
                ctx.input_text_state.cursor_pos > 0;
            if (can_backspace) {
                const cp: usize = ctx.input_text_state.cursor_pos;
                std.mem.copyForwards(u8, buf[cp - 1 .. len.* - 1], buf[cp..len.*]);
                len.* -= 1;
                ctx.input_text_state.cursor_pos -= 1;
                changed = true;
            }

            // Delete.  Gated on !read_only.
            const can_delete: bool = !opts.read_only and
                ctxKeyPressedOrRepeat(ctx, .delete) and
                ctx.input_text_state.cursor_pos < len.*;
            if (can_delete) {
                const cp: usize = ctx.input_text_state.cursor_pos;
                std.mem.copyForwards(u8, buf[cp .. len.* - 1], buf[cp + 1 .. len.*]);
                len.* -= 1;
                changed = true;
            }

            // Arrow nav.
            const cursor_pos: usize = ctx.input_text_state.cursor_pos;
            if (ctxKeyPressedOrRepeat(ctx, .left) and cursor_pos > 0) {
                ctx.input_text_state.cursor_pos = cursor_pos - 1;
                ctx.input_text_state.blink_t = 0;
            }
            if (ctxKeyPressedOrRepeat(ctx, .right) and cursor_pos < len.*) {
                ctx.input_text_state.cursor_pos = cursor_pos + 1;
                ctx.input_text_state.blink_t = 0;
            }
            // Up / Down: convert byte → (line, col), shift line, convert back.
            if (ctxKeyPressedOrRepeat(ctx, .up)) {
                const lc: LineCol = byteToLineCol(buf[0..len.*], cursor_pos);
                if (lc.line > 0) {
                    const target: LineCol = .{ .line = lc.line - 1, .col = lc.col };
                    ctx.input_text_state.cursor_pos = lineColToByte(buf[0..len.*], target);
                    ctx.input_text_state.blink_t = 0;
                }
            }
            if (ctxKeyPressedOrRepeat(ctx, .down)) {
                const lc: LineCol = byteToLineCol(buf[0..len.*], cursor_pos);
                const target: LineCol = .{ .line = lc.line + 1, .col = lc.col };
                ctx.input_text_state.cursor_pos = lineColToByte(buf[0..len.*], target);
                ctx.input_text_state.blink_t = 0;
            }

            // Home / End: line-aware.
            if (ctx.input.keys[@intFromEnum(KeyCode.home)].pressed_this_frame) {
                const lc: LineCol = byteToLineCol(buf[0..len.*], cursor_pos);
                ctx.input_text_state.cursor_pos = lineColToByte(buf[0..len.*], .{ .line = lc.line, .col = 0 });
                ctx.input_text_state.blink_t = 0;
            }
            if (ctx.input.keys[@intFromEnum(KeyCode.end)].pressed_this_frame) {
                const lc: LineCol = byteToLineCol(buf[0..len.*], cursor_pos);
                // Use a very large col; lineColToByte clamps to line length.
                ctx.input_text_state.cursor_pos = lineColToByte(
                    buf[0..len.*],
                    .{ .line = lc.line, .col = maxInt(usize) / 2 },
                );
                ctx.input_text_state.blink_t = 0;
            }
        } // end of host edit-ops branch

        // Edit callback fires once at end of all edits for this
        // frame.  Universal — fires on both host and web paths
        // whenever the buffer changed this frame.  Multiline
        // doesn't wire Completion / History; see notes on
        // InputTextOpts.completion / .history.
        if (changed) {
            if (opts.edit) |edit_cb| {
                runInputCallback(.edit, edit_cb, opts.user_data, buf, len, &ctx.input_text_state.cursor_pos, 0, .up);
            }
        }

        ctx.input_text_state.blink_t += ctx.input.delta_time;
    }

    // ---- Render
    const bg: Color = if (ctx.active_id == id)
        ctx.style.frame_bg_active
    else if (hovered)
        ctx.style.frame_bg_hovered
    else
        ctx.style.frame_bg;
    drawRectFilled(ctx, box, Color.toWire(bg));

    const content_x: f32 = box.x + padding[0];
    const content_y: f32 = box.y + padding[1];
    const empty_unfocused: bool = (len.* == 0) and (ctx.active_id != id);
    const show_hint: bool = empty_unfocused and (opts.hint.len > 0);
    if (show_hint) {
        drawTextAtS(ctx, .{ content_x, content_y }, opts.hint, Color.toWire(ctx.style.text_disabled));
    } else {
        // drawTextAtS forwards to text2d.drawWithFont, which handles
        // '\n' natively (advances off_y by font_size + line_spacing).
        drawTextAtS(ctx, .{ content_x, content_y }, buf[0..len.*], Color.toWire(ctx.style.text));
    }

    // Cursor caret - blink visible 0.0-0.5s.
    if (ctx.active_id == id) {
        const phase: f32 = @mod(ctx.input_text_state.blink_t, 1.0);
        if (phase < 0.5) {
            const lc: LineCol = byteToLineCol(buf[0..len.*], ctx.input_text_state.cursor_pos);
            // Width of text up to col on the cursor's line.
            const line_start: usize = ctx.input_text_state.cursor_pos - lc.col;
            const before: []const u8 = buf[line_start .. line_start + lc.col];
            const cursor_x: f32 = content_x + measureTextS(ctx, before)[0];
            const cursor_y: f32 = content_y + float(lc.line) * line_h;
            drawRectFilled(
                ctx,
                .{ .x = cursor_x, .y = cursor_y, .width = 1, .height = ctx.style.font_size },
                Color.toWire(ctx.style.text),
            );
        }
    }

    // Label to the right of the box (matches single-line layout).
    const text_size: Vec2 = measureTextS(ctx, label);
    drawTextAtS(
        ctx,
        .{ box.x + box.width + ctx.style.item_spacing[0], content_y },
        label,
        Color.toWire(ctx.style.text),
    );

    const total_w: f32 = box.width + ctx.style.item_spacing[0] + text_size[0];
    w.layout.last_item_id = id;
    w.layout.last_item_rect = .{ .x = box.x, .y = box.y, .width = total_w, .height = box.height };
    w.layout.last_item_hovered = hovered;
    markItemPost(ctx, w, changed);

    advanceLayout(w, .{ total_w, box.height }, ctx.style.item_spacing, ctx.style.window_padding[0]);
    // Web-path commit detection: focus dropped while widget was
    // active = caller-visible commit.  Single-line has the same
    // pattern; see inputTextImpl for the rationale.
    const on_web: bool = comptime builtin.target.cpu.arch.isWasm();
    if (on_web and was_focused_at_start and ctx.active_id != id) {
        commit_pressed = true;
    }
    // `enter_returns_true` swaps the return value's meaning from
    // "buffer changed" to "user committed."  See inputTextImpl
    // for the full reasoning + behavior table.
    if (opts.enter_returns_true) {
        return commit_pressed;
    }
    return changed;
}

// ============================================================================
// Surface stack - the recursive UI plumbing
// ============================================================================
// A "surface" is whatever the UI is currently rendering onto. By
// default it's the main canvas (screen-space pixels). An RT scope
// pushes a Surface for the duration: viewport at RT size, ortho
// matrices loaded for RT pixels, FBO bound. The scope exits via
// popRenderTextureImpl, restoring the outer state.
// Why a stack and not a single "current target"? Because recursive
// composition is the goal: render scene-A to RT-A, render a HUD with
// RT-A embedded to RT-B, render the main scene with RT-B as a cube
// material - all in one frame. Each scope is properly nestable.

/// Active surface - top of stack, or implicit canvas if empty.
fn currentSurface(ctx: *const UiContext) Surface {
    if (ctx.surface_stack.top()) |s| {
        return s;
    }
    return .{ .width = ctx.canvas_w, .height = ctx.canvas_h, .y_flipped = false };
}

test "surface_stack: empty stack reports canvas" {
    var ctx = UiContext{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    const s: Surface = currentSurface(&ctx);
    try expectEqual(@as(u32, 800), s.width);
    try expectEqual(@as(u32, 600), s.height);
    try expectEqual(false, s.y_flipped);
}

test "surface_stack: push reports RT, pop restores canvas" {
    var ctx = UiContext{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    try ctx.surface_stack.append(.{ .width = 256, .height = 256, .y_flipped = true });
    const s: Surface = currentSurface(&ctx);
    try expectEqual(@as(u32, 256), s.width);
    try expectEqual(true, s.y_flipped);
    _ = ctx.surface_stack.pop();
    const back: Surface = currentSurface(&ctx);
    try expectEqual(@as(u32, 800), back.width);
    try expectEqual(false, back.y_flipped);
}

test "surface_stack: 8-deep nesting works" {
    var ctx = UiContext{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 100,
        .canvas_h = 100,
    };
    defer ctx.deinit();
    var i: u32 = 0;
    while (i < 8) : (i += 1) {
        try ctx.surface_stack.append(.{ .width = i, .height = i });
    }
    // 9th push must fail.
    try expectError(error.Overflow, ctx.surface_stack.append(.{ .width = 99, .height = 99 }));
}

// ============================================================================
// Widget: combo (dropdown picker) - eager, inline-expansion
// ============================================================================
// Closed state: a frame_bg-colored row showing the current item's
// label with a small ▼ arrow on the right + the user's label to its
// right.
// Open state: closed row + N item rows below. Click an item to
// select + close. Click outside both → close. Click the closed row
// again → close. Layout consumes the FULL height (closed + items)
// while open, reflowing subsequent widgets downward.
// Why no floating popup: real popups need deferred draw lists so the
// dropdown overlays subsequent widgets without reflowing them - see
// `docs/raylib-ui-integration.md` Tier C, Idea 9. That's a multi-
// turn refactor. Inline expansion is the v1 trade-off: simpler,
// ships now, slightly weird visually when subsequent widgets
// suddenly get pushed down. Caller convention: put combos at the
// bottom of a window's content if reflow bothers you.

// ============================================================================
// beginCombo / endCombo (Phase B3b)
// ============================================================================
// Custom-content combo dropdown. Reuses the popup-window machinery
// (synthetic Window stored in `ctx.windows` under a `##combo_<hex>`
// key) so widgets submitted between begin/end participate in the
// usual layout pipeline (cursor advancement, hit-tests, etc.) but
// inside a popup that anchors to the closed row instead of a free
// position.
// The closed row renders just like `combo()` (preview text + arrow +
// label on the right). When open, a synthetic window is positioned
// directly under the closed row, sized to match the closed row's
// width, and pushed onto the window stack. Widgets submitted by the
// caller advance the popup's cursor; `endCombo` auto-fits height to
// `cursor_max` (the popup BG drawn at begin uses the PREVIOUS frame's
// size - one-frame lag is acceptable for dropdowns where the row count
// rarely changes).

test "comboImpl: open state persists in UiContext.combo_open_id field" {
    // The combo's persistent state lives on UiContext, not on the
    // widget's caller. This is a sanity check that the field exists
    // and defaults to "no combo open".
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expectEqual(@as(Id, 0), ctx.combo_open_id);
    ctx.combo_open_id = 0xCAFE;
    try expectEqual(@as(Id, 0xCAFE), ctx.combo_open_id);
}

// ============================================================================
// Tests: deferred draw list - proves the architectural contract
// ============================================================================

test "DrawList: empty by default, addRectFilled adds one cmd" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    try expect(dl.isEmpty());
    dl.addRectFilled(test_arena.allocator(), .{ .x = 0, .y = 0, .width = 10, .height = 10 }, 0xFFFFFFFF);
    try expect(!dl.isEmpty());
    try expectEqual(@as(usize, 1), dl.cmds.items.len);
    // Tag check - proves the union resolved to.rect_filled.
    try expect(dl.cmds.items[0] == .rect_filled);
}

test "DrawList: addText duplicates bytes into arena (caller buffer can die)" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    {
        // Strings live in a scope that ends before we read them back
        // proves the arena copy is a real copy, not a slice borrow.
        var local: [16]u8 = .{ 'H', 'e', 'l', 'l', 'o', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
        dl.addText(test_arena.allocator(), null, local[0..5], .{ 0, 0 }, 10, 0, 2, 0xFFFFFFFF);
        // Stomp the local - if addText borrowed the slice this would
        // corrupt the recorded text.
        @memset(&local, 0xAA);
    }
    // Check the recorded bytes survived.
    try expectEqual(@as(usize, 1), dl.cmds.items.len);
    const cmd: @TypeOf(dl.cmds.items[0].text) = dl.cmds.items[0].text;
    try expectEqualStrings("Hello", cmd.s);
}

test "DrawList: clear sets cmds to empty (capacity lives in arena pages)" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    // Fill up.
    var i: u32 = 0;
    while (i < 100) : (i += 1) {
        dl.addRectFilled(test_arena.allocator(), .{ .x = 0, .y = 0, .width = 1, .height = 1 }, 0);
    }
    try expectEqual(@as(usize, 100), dl.cmds.items.len);
    dl.clear();
    try expect(dl.isEmpty());
    try expectEqual(@as(usize, 0), dl.cmds.items.len);
    // Capacity is intentionally zeroed too - `cmds =.empty` zeroes
    // both items and capacity. The underlying memory is still in
    // the arena's pages; next frame, when production code calls
    // arena.reset(.retain_capacity), those pages are recycled.
    // What we're verifying here is that clear() is consistent with
    // the arena model: no stale items.ptr/cap that would survive
    // an arena reset.
    try expectEqual(@as(usize, 0), dl.cmds.capacity);
}

test "DrawList: clip_rect commands record rect, not pop_clip" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.pushClipRect(test_arena.allocator(), .{ .x = 5, .y = 5, .width = 100, .height = 50 });
    dl.popClipRect(test_arena.allocator());
    try expectEqual(@as(usize, 2), dl.cmds.items.len);
    try expect(dl.cmds.items[0] == .push_clip);
    try expect(dl.cmds.items[1] == .pop_clip);
    try expectEqual(@as(f32, 5), dl.cmds.items[0].push_clip.rect.x);
    try expectEqual(@as(f32, 100), dl.cmds.items[0].push_clip.rect.width);
}

test "DrawList: command order matches submission order (z-order foundation)" {
    // The whole point of deferring: the cmd list IS the z-order.
    // Earlier cmds render first; later cmds overdraw. Lock that
    // contract with a small synthetic submission.
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addRectFilled(test_arena.allocator(), .{ .x = 0, .y = 0, .width = 100, .height = 100 }, 0xFF000000);
    dl.addText(test_arena.allocator(), null, "below", .{ 10, 10 }, 10, 0, 2, 0xFFFFFFFF);
    dl.addRectFilled(test_arena.allocator(), .{ .x = 5, .y = 5, .width = 50, .height = 50 }, 0xFFFF00FF);
    dl.addText(test_arena.allocator(), null, "above", .{ 20, 20 }, 10, 0, 2, 0xFFFFFFFF);
    try expectEqual(@as(usize, 4), dl.cmds.items.len);
    try expect(dl.cmds.items[0] == .rect_filled);
    try expect(dl.cmds.items[1] == .text);
    try expect(dl.cmds.items[2] == .rect_filled);
    try expect(dl.cmds.items[3] == .text);
}

// ----------------------------------------------------------------------------
// DrawListSplitter
// ----------------------------------------------------------------------------
// The splitter buffers commands into N independent channels and on
// `merge()` concatenates them in channel-index order into a target
// DrawList. This decouples submission order from paint order:
// submit channel 0's commands interleaved with channel 1's, and the
// merge will still emit all channel-0 commands first. The 2-channel
// case is what will use to migrate table rendering (channel 0 =
// cell backgrounds + borders, channel 1 = cell content).

test "DrawListSplitter: default-init is empty + safe to deinit" {
    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(std.testing.allocator);
    try expectEqual(@as(usize, 0), splitter.channels.items.len);
    try expectEqual(@as(usize, 0), splitter.current);
    // Without split(), getCurrentChannel returns null defensively.
    try expectEqual(@as(?*DrawList, null), splitter.getCurrentChannel());
}

test "DrawListSplitter: split(N) yields N channels, current=0" {
    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(std.testing.allocator);
    try splitter.split(std.testing.allocator, 3);
    try expectEqual(@as(usize, 3), splitter.channels.items.len);
    try expectEqual(@as(usize, 0), splitter.current);
    // Default-selected channel is channel 0, non-null.
    try expect(splitter.getCurrentChannel() != null);
}

test "DrawListSplitter: split(0) leaves no channels (getCurrentChannel null)" {
    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(std.testing.allocator);
    try splitter.split(std.testing.allocator, 0);
    try expectEqual(@as(usize, 0), splitter.channels.items.len);
    try expectEqual(@as(?*DrawList, null), splitter.getCurrentChannel());
}

test "DrawListSplitter: setCurrentChannel switches the active channel" {
    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(std.testing.allocator);
    try splitter.split(std.testing.allocator, 3);
    splitter.setCurrentChannel(2);
    try expectEqual(@as(usize, 2), splitter.current);
    splitter.setCurrentChannel(1);
    try expectEqual(@as(usize, 1), splitter.current);
    splitter.setCurrentChannel(0);
    try expectEqual(@as(usize, 0), splitter.current);
}

test "DrawListSplitter: setCurrentChannel out-of-range clamps to last channel" {
    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(std.testing.allocator);
    try splitter.split(std.testing.allocator, 3);
    splitter.setCurrentChannel(99);
    try expectEqual(@as(usize, 2), splitter.current);
    // Empty splitter — setCurrentChannel forces current=0 (no underflow).
    var empty: DrawListSplitter = .{};
    defer empty.deinit(std.testing.allocator);
    empty.setCurrentChannel(42);
    try expectEqual(@as(usize, 0), empty.current);
}

test "DrawListSplitter: merge respects channel order, not submission order" {
    // The keystone test. Submit a rect on channel 1, then a rect on
    // channel 0, then another on channel 1. After merge, channel 0's
    // commands must come FIRST in the target — channel 1's submissions
    // bracket it in submission order but come AFTER channel 0 in the
    // merged output.
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    const alloc: Allocator = test_arena.allocator();

    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(alloc);
    try splitter.split(alloc, 2);

    // Channel 1: foreground (content)
    splitter.setCurrentChannel(1);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addText(alloc, null, "content-a", .{ 10, 10 }, 10, 0, 2, 0xFFFFFFFF);
    }
    // Channel 0: background (bg)
    splitter.setCurrentChannel(0);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addRectFilled(alloc, .{ .x = 0, .y = 0, .width = 100, .height = 100 }, 0xFF000000);
    }
    // Channel 1 again: more content (submitted AFTER bg)
    splitter.setCurrentChannel(1);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addText(alloc, null, "content-b", .{ 20, 20 }, 10, 0, 2, 0xFFFFFFFF);
    }

    var target: DrawList = .{};
    defer target.deinit(alloc);
    try splitter.merge(alloc, &target);

    // 3 commands total: rect (channel 0), text-a, text-b (channel 1).
    try expectEqual(@as(usize, 3), target.cmds.items.len);
    // Channel 0 first — the rect must be at index 0.
    try expect(target.cmds.items[0] == .rect_filled);
    // Channel 1 in submission order — text-a before text-b.
    try expect(target.cmds.items[1] == .text);
    try expect(target.cmds.items[2] == .text);
}

test "DrawListSplitter: merge empties channels for reuse" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    const alloc: Allocator = test_arena.allocator();

    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(alloc);
    try splitter.split(alloc, 2);
    splitter.setCurrentChannel(0);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addRectFilled(alloc, .{ .x = 0, .y = 0, .width = 10, .height = 10 }, 0xFFAA0000);
    }
    splitter.setCurrentChannel(1);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addRectFilled(alloc, .{ .x = 5, .y = 5, .width = 10, .height = 10 }, 0xFF00AA00);
    }

    var target: DrawList = .{};
    defer target.deinit(alloc);
    try splitter.merge(alloc, &target);
    try expectEqual(@as(usize, 2), target.cmds.items.len);

    // After merge, both channels are empty — splitter is ready for
    // another submission round. This is the per-frame loop pattern:
    // split once at table-begin, refill each frame, merge at end.
    try expectEqual(@as(usize, 0), splitter.channels.items[0].cmds.items.len);
    try expectEqual(@as(usize, 0), splitter.channels.items[1].cmds.items.len);
}

test "DrawListSplitter: re-split freshens the channel layout" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    const alloc: Allocator = test_arena.allocator();

    var splitter: DrawListSplitter = .{};
    defer splitter.deinit(alloc);
    // First split: 2 channels, submit something on channel 1.
    try splitter.split(alloc, 2);
    splitter.setCurrentChannel(1);
    if (splitter.getCurrentChannel()) |dl| {
        dl.addRectFilled(alloc, .{ .x = 0, .y = 0, .width = 1, .height = 1 }, 0xFFFFFFFF);
    }
    // Re-split to 4 channels. Previous channel storage is freed; any
    // unmerged commands are lost; current resets to 0.
    try splitter.split(alloc, 4);
    try expectEqual(@as(usize, 4), splitter.channels.items.len);
    try expectEqual(@as(usize, 0), splitter.current);
    for (splitter.channels.items) |ch| {
        try expectEqual(@as(usize, 0), ch.cmds.items.len);
    }
}

test "UiContext: foreground_dl + frame_windows initialized empty" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expect(ctx.foreground_dl.isEmpty());
    try expectEqual(@as(usize, 0), ctx.frame_windows.len);
    try expectEqual(@as(?*DrawList, null), ctx.current_draw_list);
    try expectEqual(false, ctx.eager_mode);
}

test "B3: background_dl initialized empty + clear/deinit don't crash" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expect(ctx.background_dl.isEmpty());
    // Prime then clear.
    ctx.background_dl.addRectFilled(
        ctx.frame_arena.allocator(),
        .{ .x = 0, .y = 0, .width = 10, .height = 10 },
        0xFF112233,
    );
    try expect(!ctx.background_dl.isEmpty());
    ctx.background_dl.clear();
    try expect(ctx.background_dl.isEmpty());
}

test "B3: beginFrame clears background_dl too" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.background_dl.addRectFilled(
        ctx.frame_arena.allocator(),
        .{ .x = 0, .y = 0, .width = 10, .height = 10 },
        0xFF111111,
    );
    try expect(!ctx.background_dl.isEmpty());

    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    try expect(ctx.background_dl.isEmpty());
}

test "B3: DrawListHandle.addRectFilled records on the wrapped list" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    const dl: DrawListHandle = u.getBackgroundDrawList();
    try expect(dl.list == &ctx.background_dl);
    try expectEqual(@as(usize, 0), ctx.background_dl.cmds.items.len);
    dl.addRectFilled(.{ .x = 0, .y = 0, .width = 50, .height = 50 }, Color.fromWire(0xFF445566));
    try expectEqual(@as(usize, 1), ctx.background_dl.cmds.items.len);
    switch (ctx.background_dl.cmds.items[0]) {
        .rect_filled => |rf| {
            try expectEqual(@as(f32, 50), rf.rect.width);
            try expectEqual(@as(u32, 0xFF445566), rf.col);
        },
        else => return error.UnexpectedCmd,
    }
}

test "B3: getForegroundDrawList returns handle bound to foreground_dl" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    const dl: DrawListHandle = u.getForegroundDrawList();
    try expect(dl.list == &ctx.foreground_dl);
    dl.addLine(.{ 0, 0 }, .{ 100, 100 }, Color.fromWire(0xFFFFFFFF), 1);
    try expectEqual(@as(usize, 1), ctx.foreground_dl.cmds.items.len);
    switch (ctx.foreground_dl.cmds.items[0]) {
        .line => {},
        else => return error.UnexpectedCmd,
    }
}

test "B3: background and foreground lists are independent" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    u.getBackgroundDrawList().addCircleFilled(.{ 100, 100 }, 20, Color.fromWire(0xFF223344));
    u.getForegroundDrawList().addCircleFilled(.{ 200, 200 }, 30, Color.fromWire(0xFF556677));

    try expectEqual(@as(usize, 1), ctx.background_dl.cmds.items.len);
    try expectEqual(@as(usize, 1), ctx.foreground_dl.cmds.items.len);
}

test "UiContext: beginFrame resets draw list state" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Prime to non-default.
    ctx.foreground_dl.addRectFilled(ctx.frame_arena.allocator(), .{ .x = 0, .y = 0, .width = 10, .height = 10 }, 0);
    ctx.eager_mode = true;
    // Synthesize a window pointer for frame_windows (don't dereference it).
    const fake_w_storage: *Window = @ptrFromInt(@alignOf(Window));
    ctx.frame_windows.append(fake_w_storage) catch {};

    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    try expect(ctx.foreground_dl.isEmpty());
    try expectEqual(@as(usize, 0), ctx.frame_windows.len);
    try expectEqual(@as(?*DrawList, null), ctx.current_draw_list);
    try expectEqual(false, ctx.eager_mode);
}

test "DrawList: draw2d rect/rectXYWH/circle wrap the add* methods" {
    const alloc = std.testing.allocator;
    const r: Rectangle = .{ .x = 5, .y = 10, .width = 40, .height = 20 };
    const c: Color = .{ .r = 200, .g = 40, .b = 40, .a = 255 };

    // draw2d rect(struct) == direct addRectFilled(toWire)
    var dl_a: DrawList = .{};
    defer dl_a.deinit(alloc);
    const ha: DrawListHandle = .{ .list = &dl_a, .gpa = alloc };
    ha.rect(r, .{ .color = c });

    var dl_b: DrawList = .{};
    defer dl_b.deinit(alloc);
    dl_b.addRectFilled(alloc, r, c.toWire());

    try expectEqual(@as(usize, 1), dl_a.cmds.items.len);
    try expectEqual(dl_b.cmds.items.len, dl_a.cmds.items.len);
    try expectEqual(dl_b.cmds.items[0].rect_filled.col, dl_a.cmds.items[0].rect_filled.col);
    try expectEqual(r.width, dl_a.cmds.items[0].rect_filled.rect.width);

    // rectXYWH(numbers) produces the same command as rect(struct)
    var dl_c: DrawList = .{};
    defer dl_c.deinit(alloc);
    const hc: DrawListHandle = .{ .list = &dl_c, .gpa = alloc };
    hc.rectXYWH(5, 10, 40, 20, .{ .color = c });
    try expectEqual(dl_a.cmds.items[0].rect_filled.col, dl_c.cmds.items[0].rect_filled.col);
    try expectEqual(dl_a.cmds.items[0].rect_filled.rect.height, dl_c.cmds.items[0].rect_filled.rect.height);

    // circle records something
    var dl_d: DrawList = .{};
    defer dl_d.deinit(alloc);
    const hd: DrawListHandle = .{ .list = &dl_d, .gpa = alloc };
    hd.circle(.{ 20, 20 }, 8, .{ .color = c });
    try expect(!dl_d.isEmpty());

    // line records something
    var dl_e: DrawList = .{};
    defer dl_e.deinit(alloc);
    const he: DrawListHandle = .{ .list = &dl_e, .gpa = alloc };
    he.line(.{ 0, 0 }, .{ 10, 10 }, .{ .color = c, .thickness = 2 });
    try expect(!dl_e.isEmpty());

    // triangle records something
    var dl_f: DrawList = .{};
    defer dl_f.deinit(alloc);
    const hf: DrawListHandle = .{ .list = &dl_f, .gpa = alloc };
    hf.triangle(.{ 0, 0 }, .{ 10, 0 }, .{ 5, 8 }, .{ .color = c });
    try expect(!dl_f.isEmpty());
}

test "DrawList: unpackColor round-trips with colorToU32 (used in replay)" {
    // The recorded col is a packed u32; replay unpacks back to Color.
    // Round-trip must be exact for visual equivalence with the eager path.
    const original = Color{ .r = 0x12, .g = 0x34, .b = 0x56, .a = 0x78 };
    const packed_col: ColorU32 = Color.toWire(original);
    const round_col: Color = Color.fromWire(packed_col);
    try expectEqual(original.r, round_col.r);
    try expectEqual(original.g, round_col.g);
    try expectEqual(original.b, round_col.b);
    try expectEqual(original.a, round_col.a);
}

// ============================================================================
// Tests: beginChild / endChild - sub-layout + clipping correctness
// ============================================================================

test "child windows: persist in ctx.windows across frames" {
    // (child-as-Window): each `beginChild` allocates a
    // persistent `Window` keyed by hashed child id, stored in
    // `ctx.windows`. The same child must be found again on the
    // next frame so per-child state (scroll, focus, future dock
    // node) survives. This test exercises the persistence
    // contract directly: hash the same str_id twice, fetch from
    // ctx.windows both times, expect the same pointer.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // No children yet.
    try expectEqual(@as(usize, 0), ctx.windows.count());

    // Simulate two beginChild calls with the same str_id under the
    // same parent ID stack - should fetch the same Window pointer.
    const parent_id: Id = 0xDEADBEEF;
    const child_id: Id = hashStr(parent_id, "panel");
    const first: *Window = findOrCreateChildWindow(&ctx, child_id);
    const second: *Window = findOrCreateChildWindow(&ctx, child_id);
    try expect(first == second);
    try expectEqual(@as(usize, 1), ctx.windows.count());
    // Hash with a different str_id → different Window.
    const sibling_id: Id = hashStr(parent_id, "other");
    const sibling: *Window = findOrCreateChildWindow(&ctx, sibling_id);
    try expect(sibling != first);
    try expectEqual(@as(usize, 2), ctx.windows.count());
    // Flag check: children are flagged as such.
    try expect(first.flags.is_child);
    try expect(sibling.flags.is_child);
}

test "child scroll_max_y reflects content overflow" {
    // After endChild, the child's scroll_max_y must equal
    // (content_height - viewport_height) when content overflows.
    // Zero when content fits. Pin both cases.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // ---- Case 1: content overflows ---------------------------------
    // Child is 200×100; submit 10 buttons each ~22px tall =
    // content_h ≈ 220 + spacings. scroll_max_y should be positive.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();

        var child_id: Id = 0;
        if (ui.beginChild("scrolly", .{ 200, 100 }, .{})) {
            defer ui.endChild();
            child_id = ctx.current_window.?.id;
            var i: usize = 0;
            while (i < 10) : (i += 1) {
                _ = ui.button("row", .{});
            }
        }

        const child: *Window = ctx.windows.get(child_id).?;
        try expect(child.scroll_max_y > 0);
        // 10 rows of (~24px button + 6px spacing) ≈ 280px of CONTENT
        // but only ~196 lands as `cursor_max` for our 10-button stack
        // (font_size 16 + 2*frame_padding.y 4 = 24, no spacing after
        // the last row). Viewport is 100 (default ChildFlags → no
        // padding). Substantial overflow expected;
        // the > 80 bound proves the scrolling math is right without
        // pinning the exact value (depends on font metrics).
        try expect(child.scroll_max_y > 80);
    }

    // ---- Case 2: content fits, scroll_max_y stays zero -------------
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();

        var fits_id: Id = 0;
        if (ui.beginChild("fits", .{ 200, 300 }, .{})) {
            defer ui.endChild();
            fits_id = ctx.current_window.?.id;
            _ = ui.button("only one", .{});
        }

        const child: *Window = ctx.windows.get(fits_id).?;
        try expectEqual(@as(f32, 0), child.scroll_max_y);
    }
}

test "ChildFlags: default (no flags) → no inner padding" {
    // Imgui-parity: ChildFlags{} means the child's
    // content starts at the child's exact corner. No window
    // padding, no border. This is `ImGuiChildFlags_None`.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window(
        "host",
        .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } },
    ) orelse unreachable;
    defer h.close();
    if (ui.beginChild("c", .{ 200, 200 }, .{})) {
        defer ui.endChild();
        const child: *Window = ctx.current_window.?;
        // Origin == child's outer top-left (no padding inset).
        try expectApproxEqAbs(child.pos[0], child.layout.origin[0], 0.01);
        try expectApproxEqAbs(child.pos[1], child.layout.origin[1], 0.01);
        // work_rect_max == outer bottom-right (no right/bottom inset).
        try expectApproxEqAbs(child.pos[0] + child.size[0], child.layout.work_rect_max[0], 0.01);
        try expectApproxEqAbs(child.pos[1] + child.size[1], child.layout.work_rect_max[1], 0.01);
    }
}

test "ChildFlags: border enables window-padding" {
    // `border = true` implicitly enables padding (a 1px border with
    // content flush against it looks bad). Pad amount: style.window_padding.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window(
        "host",
        .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } },
    ) orelse unreachable;
    defer h.close();
    const pad: Vec2 = ctx.style.window_padding;
    if (ui.beginChild("bc", .{ 200, 200 }, .{ .border = true })) {
        defer ui.endChild();
        const child: *Window = ctx.current_window.?;
        try expectApproxEqAbs(child.pos[0] + pad[0], child.layout.origin[0], 0.01);
        try expectApproxEqAbs(child.pos[1] + pad[1], child.layout.origin[1], 0.01);
        try expectApproxEqAbs(child.pos[0] + child.size[0] - pad[0], child.layout.work_rect_max[0], 0.01);
        try expectApproxEqAbs(child.pos[1] + child.size[1] - pad[1], child.layout.work_rect_max[1], 0.01);
    }
}

test "ChildFlags: always_use_window_padding without border" {
    // Padding without the border render. Useful for tight panels
    // that want breathing room but no visible outline.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window(
        "host",
        .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } },
    ) orelse unreachable;
    defer h.close();
    const pad: Vec2 = ctx.style.window_padding;
    if (ui.beginChild("pc", .{ 200, 200 }, .{ .always_use_window_padding = true })) {
        defer ui.endChild();
        const child: *Window = ctx.current_window.?;
        try expectApproxEqAbs(child.pos[0] + pad[0], child.layout.origin[0], 0.01);
        try expectApproxEqAbs(child.pos[1] + pad[1], child.layout.origin[1], 0.01);
    }
}

test "mouse wheel consumed by deepest hovered child" {
    // Inner child's endChildImpl runs BEFORE outer's (LIFO via the
    // user's `defer ui.endChild()` pattern). If the cursor is
    // over both, inner should consume the wheel; outer should see
    // `mouse_wheel_consumed = true` and skip applying.
    // Test shape: outer at host's origin, inner FIRST inside outer
    // (so inner has real space at outer's inner-origin), then outer
    // has 20 more rows of scrollable content. Mouse hit-tests both
    // - inner only gets the wheel because it runs first.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // First frame: discover the children's actual screen rects so
    // we can place the mouse correctly on the second frame. Wheel
    // = 0 so nothing scrolls yet.
    var outer_id: Id = 0;
    var inner_id: Id = 0;
    var inner_rect: Rectangle = undefined;
    {
        const snapshot: InputSnapshot = .{};
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "host",
            .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 800, 600 } },
        ) orelse unreachable;
        defer h.close();

        if (ui.beginChild("outer", .{ 200, 200 }, .{})) {
            defer ui.endChild();
            outer_id = ctx.current_window.?.id;
            if (ui.beginChild("inner", .{ 120, 120 }, .{})) {
                defer ui.endChild();
                const inner: *Window = ctx.current_window.?;
                inner_id = inner.id;
                inner_rect = .{
                    .x = inner.pos[0],
                    .y = inner.pos[1],
                    .width = inner.size[0],
                    .height = inner.size[1],
                };
                // Submit 20 rows so inner overflows.
                var i: usize = 0;
                while (i < 20) : (i += 1) {
                    _ = ui.button("rowI", .{});
                }
            }
            // Then 20 rows in outer to make IT overflow too.
            var j: usize = 0;
            while (j < 20) : (j += 1) {
                _ = ui.button("rowO", .{});
            }
        }
    }
    // Sanity: both children allocated.
    try expect(outer_id != 0);
    try expect(inner_id != 0);

    // Second frame: place the mouse inside the inner rect (which
    // is also inside the outer rect, since inner is nested), wheel
    // down → both children COULD scroll but only inner should.
    const inner_center: Vec2 = .{
        inner_rect.x + inner_rect.width / 2,
        inner_rect.y + inner_rect.height / 2,
    };
    {
        const snapshot: InputSnapshot = .{
            .mouse_pos = inner_center,
            .mouse_wheel_y = -1, // negative → scroll DOWN (scroll_y increases)
        };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();

        if (ui.beginChild("outer", .{ 200, 200 }, .{})) {
            defer ui.endChild();
            if (ui.beginChild("inner", .{ 120, 120 }, .{})) {
                defer ui.endChild();
                var i: usize = 0;
                while (i < 20) : (i += 1) {
                    _ = ui.button("rowI", .{});
                }
            }
            var j: usize = 0;
            while (j < 20) : (j += 1) {
                _ = ui.button("rowO", .{});
            }
        }
    }

    const inner: *Window = ctx.windows.get(inner_id).?;
    const outer: *Window = ctx.windows.get(outer_id).?;
    // Inner consumed the wheel - scroll_y > 0.
    try expect(inner.scroll_y > 0);
    // Outer saw consumed=true, did not scroll.
    try expectEqual(@as(f32, 0), outer.scroll_y);
}

test "HoveredFlags.any_window - non-zero hovered_window_id" {
    // The simplest meaningful flag: `.any_window` returns true iff
    // ANY window has the mouse, i.e. `ctx.hovered_window_id != 0`.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: mouse far away from any window - none hovered.
    {
        const snapshot: InputSnapshot = .{ .mouse_pos = .{ -100, -100 } };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "solo",
            .{ .initial_pos = .{ 10, 10 }, .initial_size = .{ 100, 100 } },
        ) orelse unreachable;
        defer h.close();
        try expect(!ui.isWindowHovered(.{}));
        try expect(!ui.isWindowHovered(.{ .any_window = true }));
    }

    // Frame 2: mouse inside the window - both forms true.
    {
        const snapshot: InputSnapshot = .{ .mouse_pos = .{ 50, 50 } };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("solo", .{}) orelse unreachable;
        defer h.close();
        try expect(ui.isWindowHovered(.{}));
        try expect(ui.isWindowHovered(.{ .any_window = true }));
    }
}

test "HoveredFlags.child_windows - parent reports hovered when child is hovered" {
    // Two-frame test (frame 1 discovers child geometry, frame 2
    // places mouse in the child and queries from the parent's scope).
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    var child_center: Vec2 = .{ 0, 0 };
    var child_id_capture: Id = 0;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "host",
            .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } },
        ) orelse unreachable;
        defer h.close();
        if (ui.beginChild("c", .{ 120, 120 }, .{})) {
            defer ui.endChild();
            const c: *Window = ctx.current_window.?;
            child_id_capture = c.id;
            child_center = .{ c.pos[0] + c.size[0] / 2, c.pos[1] + c.size[1] / 2 };
        }
    }
    try expect(child_id_capture != 0);

    // Frame 2: place the mouse at the child's center, fake the
    // hovered_window_id to be the child (deepest-hovered semantics
    // aren't fully wired today - `hovered_window_id` tracks the
    // top-level only - so we override directly to exercise the
    // child_windows traversal logic). Once.x lands deeper
    // hover-tracking, this fake becomes unnecessary.
    {
        const snapshot: InputSnapshot = .{ .mouse_pos = child_center };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        // Force ctx.hovered_window_id to the child so the traversal
        // logic has something to walk back from.
        ctx.hovered_window_id = child_id_capture;
        // Parent scope query, no flags: child is hovered, parent is NOT.
        try expect(!ui.isWindowHovered(.{}));
        // Parent scope query, child_windows = true: TRUE (parent
        // walked back from child via parent_id chain).
        try expect(ui.isWindowHovered(.{ .child_windows = true }));
        // Convenience constant root_and_child_windows: also TRUE.
        try expect(ui.isWindowHovered(HoveredFlags.root_and_child_windows));
        // Now query from inside the child's scope:
        if (ui.beginChild("c", .{ 120, 120 }, .{})) {
            defer ui.endChild();
            // From child's scope, isWindowHovered() is TRUE (the
            // child IS the hovered window).
            try expect(ui.isWindowHovered(.{}));
            // root_window from child: walks UP to host; host is NOT
            // the hovered window (child is) → FALSE without
            // child_windows.
            try expect(!ui.isWindowHovered(.{ .root_window = true }));
            // root_window + child_windows: walks to host, then
            // checks "is anyone in host's hierarchy hovered?" → TRUE.
            try expect(ui.isWindowHovered(HoveredFlags.root_and_child_windows));
        }
    }
}

test "FocusedFlags mirrors HoveredFlags traversal" {
    // Mostly checks the symmetric shape - the logic is shared with
    // hovered, just keyed on focused_window_id.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    var child_id_capture: Id = 0;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "host",
            .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } },
        ) orelse unreachable;
        defer h.close();
        if (ui.beginChild("c", .{ 120, 120 }, .{})) {
            defer ui.endChild();
            child_id_capture = ctx.current_window.?.id;
        }
    }
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        // Force focus on the child.
        ctx.focused_window_id = child_id_capture;
        try expect(!ui.isWindowFocused(.{}));
        try expect(ui.isWindowFocused(.{ .child_windows = true }));
        try expect(ui.isWindowFocused(FocusedFlags.root_and_child_windows));
        try expect(ui.isWindowFocused(.{ .any_window = true }));
    }
}

test "DrawList: push/pop clip rect on child boundaries (foundation for tables)" {
    // beginChild emits a `push_clip` cmd; endChild emits a `pop_clip`.
    // The pair brackets the child's content cmds. Verify the cmd
    // structure of a synthetic child submission directly (without
    // running a full UiContext frame).
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    // Simulate: parent emits 1 rect, then beginChild, child emits 2
    // rects, endChild, parent emits 1 more rect.
    dl.addRectFilled(test_arena.allocator(), .{ .x = 0, .y = 0, .width = 10, .height = 10 }, 0);
    dl.pushClipRect(test_arena.allocator(), .{ .x = 5, .y = 5, .width = 100, .height = 50 });
    dl.addRectFilled(test_arena.allocator(), .{ .x = 10, .y = 10, .width = 5, .height = 5 }, 0);
    dl.addRectFilled(test_arena.allocator(), .{ .x = 15, .y = 15, .width = 5, .height = 5 }, 0);
    dl.popClipRect(test_arena.allocator());
    dl.addRectFilled(test_arena.allocator(), .{ .x = 0, .y = 60, .width = 10, .height = 10 }, 0);
    // 5 cmds: rect, push, rect, rect, pop, rect - wait, that's 6.
    try expectEqual(@as(usize, 6), dl.cmds.items.len);
    try expect(dl.cmds.items[0] == .rect_filled);
    try expect(dl.cmds.items[1] == .push_clip);
    try expect(dl.cmds.items[2] == .rect_filled);
    try expect(dl.cmds.items[3] == .rect_filled);
    try expect(dl.cmds.items[4] == .pop_clip);
    try expect(dl.cmds.items[5] == .rect_filled);
}

test "DrawList: nested clip rects (child-in-child) record both pushes" {
    // The nested-child case from the demo - outer push, inner push,
    // some content, inner pop, outer pop. At replay time, the
    // scissor stack composes: inner content is clipped by both
    // rects (effectively the intersection).
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.pushClipRect(test_arena.allocator(), .{ .x = 0, .y = 0, .width = 200, .height = 140 });
    dl.pushClipRect(test_arena.allocator(), .{ .x = 10, .y = 20, .width = 140, .height = 80 });
    dl.addRectFilled(test_arena.allocator(), .{ .x = 12, .y = 22, .width = 4, .height = 4 }, 0);
    dl.popClipRect(test_arena.allocator());
    dl.popClipRect(test_arena.allocator());
    try expectEqual(@as(usize, 5), dl.cmds.items.len);
    // First two are pushes (outer then inner).
    try expect(dl.cmds.items[0] == .push_clip);
    try expect(dl.cmds.items[1] == .push_clip);
    // Outer push has the bigger rect.
    try expectEqual(@as(f32, 200), dl.cmds.items[0].push_clip.rect.width);
    try expectEqual(@as(f32, 140), dl.cmds.items[1].push_clip.rect.width);
    // Last two are pops.
    try expect(dl.cmds.items[3] == .pop_clip);
    try expect(dl.cmds.items[4] == .pop_clip);
}

test "DrawList: addLine records correct payload" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addLine(
        test_arena.allocator(),
        .{ 1, 2 },
        .{ 10, 20 },
        0xFFAABBCC,
        2.5,
    );
    try expectEqual(@as(usize, 1), dl.cmds.items.len);
    const c: @TypeOf(dl.cmds.items[0].line) = dl.cmds.items[0].line;
    try expectEqual(@as(f32, 1), c.a[0]);
    try expectEqual(@as(f32, 20), c.b[1]);
    try expectEqual(@as(f32, 2.5), c.thickness);
}

test "DrawList: addPolyline duplicates points into arena" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    {
        var local: [3]Vec2 = .{
            .{ 0, 0 },
            .{ 10, 5 },
            .{ 20, 0 },
        };
        dl.addPolyline(test_arena.allocator(), local[0..], 0xFF00FF00, 1.0, false);
        // Stomp - proves the arena owns the bytes, not the caller.
        for (&local) |*p| {
            p.* = .{ -1, -1 };
        }
    }
    try expectEqual(@as(usize, 1), dl.cmds.items.len);
    const c: @TypeOf(dl.cmds.items[0].polyline) = dl.cmds.items[0].polyline;
    try expectEqual(@as(usize, 3), c.points.len);
    try expectEqual(@as(f32, 10), c.points[1][0]);
    try expectEqual(@as(f32, 5), c.points[1][1]);
}

test "DrawList: addPolyline with fewer than 2 points is dropped" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    const single: [1]Vec2 = .{.{ 0, 0 }};
    dl.addPolyline(test_arena.allocator(), single[0..], 0xFFFFFFFF, 1, false);
    try expect(dl.isEmpty());
}

test "DrawList: triangle + triangle_filled records distinct tags" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addTriangle(
        test_arena.allocator(),
        .{ 0, 0 },
        .{ 10, 0 },
        .{ 5, 10 },
        0xFFFFFFFF,
        1.0,
    );
    dl.addTriangleFilled(
        test_arena.allocator(),
        .{ 20, 0 },
        .{ 30, 0 },
        .{ 25, 10 },
        0xFF00FF00,
    );
    try expectEqual(@as(usize, 2), dl.cmds.items.len);
    try expect(dl.cmds.items[0] == .triangle);
    try expect(dl.cmds.items[1] == .triangle_filled);
}

test "DrawList: circle with n_segments=0 records 0 (replay computes auto)" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addCircle(test_arena.allocator(), .{ 50, 50 }, 25.0, 0xFFFFFFFF, 1.0, 0);
    dl.addCircleFilled(test_arena.allocator(), .{ 100, 50 }, 10.0, 0xFFAA0000, 8);
    try expectEqual(@as(u32, 0), dl.cmds.items[0].circle.n_segments);
    try expectEqual(@as(u32, 8), dl.cmds.items[1].circle_filled.n_segments);
}

test "DrawList: autoCircleSegments stays in clamped range" {
    // Tiny radius - bumps up to the min.
    try expectEqual(@as(u32, 12), DrawList.autoCircleSegments(0.1));
    try expectEqual(@as(u32, 12), DrawList.autoCircleSegments(1.0));
    // Reasonable radius - somewhere in the middle.
    const mid: u32 = DrawList.autoCircleSegments(50.0);
    try expect(mid > 12 and mid < 512);
    // Huge radius - clamps at max.
    try expectEqual(@as(u32, 512), DrawList.autoCircleSegments(1.0e9));
}

test "DrawList: ngon stores explicit side count" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addNgonFilled(test_arena.allocator(), .{ 0, 0 }, 10, 6, 0, 0xFFFFFFFF);
    try expectEqual(@as(u32, 6), dl.cmds.items[0].ngon_filled.sides);
}

test "DrawList: rect_filled_multi_color stores 4 corners" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addRectFilledMultiColor(
        test_arena.allocator(),
        .{ .x = 0, .y = 0, .width = 100, .height = 50 },
        0xFF0000FF, // red TL
        0xFF00FF00, // green TR
        0xFFFF0000, // blue BR
        0xFFFFFF00, // cyan BL
    );
    const c: @TypeOf(dl.cmds.items[0].rect_filled_multi_color) = dl.cmds.items[0].rect_filled_multi_color;
    try expectEqual(@as(u32, 0xFF0000FF), c.c_tl);
    try expectEqual(@as(u32, 0xFFFF0000), c.c_br);
}

test "DrawList: bezier_cubic + ellipse + quad_filled record and dispatch by tag" {
    var test_arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer test_arena.deinit();
    var dl = DrawList{};
    defer dl.deinit(test_arena.allocator());
    dl.addBezierCubic(
        test_arena.allocator(),
        .{ 0, 0 },
        .{ 10, 20 },
        .{ 30, 20 },
        .{ 40, 0 },
        0xFFFFFFFF,
        2.0,
    );
    dl.addEllipse(test_arena.allocator(), .{ 50, 50 }, 20, 10, 0xFFFFFFFF, 1.0);
    dl.addEllipseFilled(test_arena.allocator(), .{ 100, 50 }, 15, 8, 0xFF0000FF);
    dl.addQuadFilled(
        test_arena.allocator(),
        .{ 0, 0 },
        .{ 10, 0 },
        .{ 10, 10 },
        .{ 0, 10 },
        0xFFFFFFFF,
    );
    try expectEqual(@as(usize, 4), dl.cmds.items.len);
    try expect(dl.cmds.items[0] == .bezier_cubic);
    try expect(dl.cmds.items[1] == .ellipse);
    try expect(dl.cmds.items[2] == .ellipse_filled);
    try expect(dl.cmds.items[3] == .quad_filled);
}

// ============================================================================
// Tests: multi-component widgets - slider / drag / input over `*[N]T`
// ============================================================================
// These exercise the dispatch path: `slider(&my_array, opts)` must
// resolve to the array path (sliderArray / dragArray / inputArray),
// and the per-component IDs must not collide with each other or with
// scalar sliders at the same label.

test "sliderArray ID hashing: component IDs distinct from parent and each other" {
    // The array dispatch hashes `(parent_id, i)` for each component.
    // Verify those hashes are distinct so focus state can't alias
    // across components or against the parent's own id.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const parent: Id = hashStr(0, "vec3");
    const c0: Id = hashInt(parent, 0);
    const c1: Id = hashInt(parent, 1);
    const c2: Id = hashInt(parent, 2);
    try expect(parent != c0);
    try expect(c0 != c1);
    try expect(c1 != c2);
    try expect(c0 != c2);
}

test "slider dispatches *[3]f32 to array path (compile + no crash)" {
    // Compile-time check that the dispatch resolves; runtime check
    // that the call doesn't panic when invoked outside a window
    // (early-returns false via current_window == null).
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var v: [3]f32 = .{ 0.1, 0.2, 0.3 };
    const changed: bool = u.slider("vec3", &v, .{ .min = 0, .max = 1 });
    try expectEqual(false, changed); // no window → early-return
    // Values untouched.
    try expectEqual(@as(f32, 0.1), v[0]);
    try expectEqual(@as(f32, 0.2), v[1]);
    try expectEqual(@as(f32, 0.3), v[2]);
}

test "drag dispatches *[4]i32 to array path" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var v: [4]i32 = .{ 10, 20, 30, 40 };
    const changed: bool = u.drag("rect", &v, .{ .speed = 1 });
    try expectEqual(false, changed);
    try expectEqual(@as(i32, 40), v[3]);
}

test "input dispatches *[2]f32 to array path" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var v: [2]f32 = .{ 1.5, -2.5 };
    const changed: bool = u.input("pos", &v, .{});
    try expectEqual(false, changed);
    try expectEqual(@as(f32, -2.5), v[1]);
}

// ============================================================================
// Tests: plotLines / plotHistogram - sparkline-style plots
// ============================================================================

test "plotLines: no-op without an active window (early return)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const data: [4]f32 = .{ 0, 1, 2, 3 };
    u.plotLines("fps", data[0..], .{ .min = 0, .max = 10 });
    // No window → nothing recorded; just must not panic.
    try expectEqual(@as(usize, 0), ctx.foreground_dl.cmds.items.len);
}

test "plotHistogram: empty values slice is a clean no-op" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    u.plotHistogram("empty", &.{}, .{});
    // No window or content → no panic. The point of this test is
    // just to prove the empty-input path doesn't divide by zero
    // or otherwise fault.
    try expectEqual(@as(usize, 0), ctx.foreground_dl.cmds.items.len);
}

test "PlotOpts: default min == max triggers auto-scale (semantic)" {
    // The plot impl detects `min == max` and computes range from
    // the data itself. We don't have a window-less way to assert
    // the resulting visuals; this test just verifies the
    // PlotOpts struct defaults match the documented auto-scale
    // convention (both zero).
    const opts: PlotOpts = .{};
    try expectEqual(opts.min, opts.max);
    try expectEqual(@as(f32, 0), opts.min);
}

// ============================================================================
// Tests: HSV / RGB round-trips for colorPicker
// ============================================================================

test "hsvToRgb: primary colors land where expected" {
    // Pure red, green, blue at S=V=1.
    const red: [3]f32 = hsvToRgb3(0.0, 1.0, 1.0);
    try expectApproxEqAbs(@as(f32, 1), red[0], 1e-6);
    try expectApproxEqAbs(@as(f32, 0), red[1], 1e-6);
    try expectApproxEqAbs(@as(f32, 0), red[2], 1e-6);

    const green: [3]f32 = hsvToRgb3(1.0 / 3.0, 1.0, 1.0);
    try expectApproxEqAbs(@as(f32, 0), green[0], 1e-6);
    try expectApproxEqAbs(@as(f32, 1), green[1], 1e-6);

    const blue: [3]f32 = hsvToRgb3(2.0 / 3.0, 1.0, 1.0);
    try expectApproxEqAbs(@as(f32, 0), blue[0], 1e-6);
    try expectApproxEqAbs(@as(f32, 1), blue[2], 1e-6);
}

test "hsvToRgb: zero saturation gives grayscale at V" {
    const gray: [3]f32 = hsvToRgb3(0.42, 0.0, 0.6);
    try expectApproxEqAbs(@as(f32, 0.6), gray[0], 1e-6);
    try expectApproxEqAbs(@as(f32, 0.6), gray[1], 1e-6);
    try expectApproxEqAbs(@as(f32, 0.6), gray[2], 1e-6);
}

test "rgb→hsv→rgb round-trip preserves the original" {
    const samples: [4][3]f32 = .{
        .{ 0.85, 0.25, 0.35 },
        .{ 0.20, 0.55, 0.95 },
        .{ 0.40, 0.85, 0.30 },
        .{ 0.12, 0.34, 0.56 },
    };
    for (samples) |rgb| {
        const hsv: [3]f32 = rgbToHsv3(rgb[0], rgb[1], rgb[2]);
        const rt: [3]f32 = hsvToRgb3(hsv[0], hsv[1], hsv[2]);
        try expectApproxEqAbs(rgb[0], rt[0], 1e-5);
        try expectApproxEqAbs(rgb[1], rt[1], 1e-5);
        try expectApproxEqAbs(rgb[2], rt[2], 1e-5);
    }
}

test "colorPicker: no-op without an active window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var rgba: [4]f32 = .{ 0.5, 0.5, 0.5, 1 };
    const changed: bool = u.colorPicker("c", &rgba, .{});
    try expectEqual(false, changed);
    try expectEqual(@as(f32, 0.5), rgba[0]); // untouched
}

// ============================================================================
// Tests: tables - layout core ()
// ============================================================================

test "beginTable: rejected without an active window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const ok: bool = u.beginTable("t", 3, .{});
    try expectEqual(false, ok);
    try expect(ctx.active_table == null);
}

test "beginTable: rejected when n_columns is 0 or above MAX" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;

    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(false, u.beginTable("zero", 0, .{}));
    try expectEqual(false, u.beginTable("huge", 9999, .{}));
    try expect(ctx.active_table == null);
}

// ----------------------------------------------------------------------------
// table 2-channel render
// ----------------------------------------------------------------------------
// beginTable allocates a 2-channel DrawListSplitter on TableState
// and redirects ctx.current_draw_list to channel 1. Cell bg/borders
// the table draws itself go to channel 0; widget submissions during
// cells go to channel 1. endTable merges channel 0 BEFORE channel 1
// into the window's draw_list — bg lands UNDER content in the final
// stream. These tests pin the contract.

test "table: beginTable redirects current_draw_list to splitter channel 1" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    // Pre-condition: current_draw_list points at the window's draw_list.
    ctx.current_draw_list = &w.draw_list;
    const saved_before: ?*DrawList = ctx.current_draw_list;

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{}));
    const ts: *TableState = &ctx.active_table.?;
    // Splitter set up with 2 channels.
    try expectEqual(@as(usize, 2), ts.splitter.channels.items.len);
    // Saved old draw list.
    try expectEqual(saved_before, ts.saved_draw_list);
    // Redirected to channel 1.
    try expectEqual(
        @as(*DrawList, &ts.splitter.channels.items[1]),
        ctx.current_draw_list.?,
    );
}

test "table: endTable restores current_draw_list and merges channels in order" {
    // The keystone test: drive a 2-row, 1-column table with per-row
    // bg colors enabled. After endTable, the window's draw_list
    // contains channel-0 cmds (the row bgs) BEFORE channel-1 cmds
    // (any cell content) — proving bg paints UNDER content.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };
    ctx.style.table_row_bg = .{ .r = 32, .g = 32, .b = 32, .a = 255 };
    ctx.style.table_row_bg_alt = .{ .r = 64, .g = 64, .b = 64, .a = 255 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{ .row_bg = true }));
    const ts: *TableState = &ctx.active_table.?;

    // Row 0: submit a "content" cmd into channel 1 by writing
    // through the redirected current_draw_list.
    u.tableNextRow();
    _ = u.tableNextColumn();
    ctx.current_draw_list.?.addRectFilled(
        ctx.frame_arena.allocator(),
        .{ .x = 10, .y = 10, .width = 50, .height = 20 },
        0xFFFFFFFF, // white content rect — distinct from bgs.
    );

    // Row 1: same — second content cmd.
    u.tableNextRow();
    _ = u.tableNextColumn();
    ctx.current_draw_list.?.addRectFilled(
        ctx.frame_arena.allocator(),
        .{ .x = 10, .y = 30, .width = 50, .height = 20 },
        0xFFEEEEEE,
    );

    // Sanity before endTable: channel 1 has 2 content rects, channel 0
    // has at least row-0's bg (painted lazily by tableNextRow for row 1).
    try expectEqual(@as(usize, 2), ts.splitter.channels.items[1].cmds.items.len);

    u.endTable();

    // Post-condition: ctx.active_table cleared, current_draw_list restored.
    try expect(ctx.active_table == null);
    try expectEqual(@as(?*DrawList, &w.draw_list), ctx.current_draw_list);

    // Window's draw_list now contains the merged stream. Find the
    // first content cmd (white, 0xFFFFFFFF) and the first bg cmd
    // (dark grey table_row_bg) — bg's index must be < content's.
    var first_content_idx: ?usize = null;
    var first_bg_idx: ?usize = null;
    for (w.draw_list.cmds.items, 0..) |c, i| {
        if (c != .rect_filled) {
            continue;
        }
        const col: ColorU32 = c.rect_filled.col;
        if (col == 0xFFFFFFFF and first_content_idx == null) {
            first_content_idx = i;
        }
        // table_row_bg = rgba(32,32,32,255) → packed 0xFF202020.
        if (col == 0xFF202020 and first_bg_idx == null) {
            first_bg_idx = i;
        }
    }
    try expect(first_bg_idx != null);
    try expect(first_content_idx != null);
    // The contract: bg index < content index. Before this
    // was the OTHER way around — bg appended after content, painting
    // over it. The splitter merge inverts that order.
    try expect(first_bg_idx.? < first_content_idx.?);
}

test "table: splitter merge appends to existing target draw_list" {
    // Confirms the merge target is the saved current_draw_list (i.e.
    // the window's draw_list when not nested), and that pre-existing
    // commands on that target survive — the merge APPENDS, doesn't
    // replace.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    // Pre-table: paint a sentinel cmd into the window's draw_list.
    ctx.current_draw_list.?.addRectFilled(
        ctx.frame_arena.allocator(),
        .{ .x = 0, .y = 0, .width = 1, .height = 1 },
        0xFFDEADBE, // sentinel color
    );
    try expectEqual(@as(usize, 1), w.draw_list.cmds.items.len);

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{ .row_bg = true }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    u.endTable();

    // After endTable: the sentinel cmd is STILL the first cmd in the
    // window's draw_list (merge appended after it, not replaced it).
    try expect(w.draw_list.cmds.items.len > 1);
    try expect(w.draw_list.cmds.items[0] == .rect_filled);
    try expectEqual(@as(u32, 0xFFDEADBE), w.draw_list.cmds.items[0].rect_filled.col);
}

// ----------------------------------------------------------------------------
// table cell bg colors
// ----------------------------------------------------------------------------
// `tableSetCellBgColor` records a pending bg for the column just
// entered via `tableNextColumn`. The bg is flushed at row
// finalization (tableNextRow for completed rows; endTable for the
// final row). Painted to splitter channel 0 (bg layer) AFTER the
// row bg, so cell-level overrides win over row-level visually but
// still land UNDER cell content (channel 1).

test "cell bg: TableColumnState.cell_bg_pending defaults to null" {
    const col: TableColumnState = .{};
    try expectEqual(@as(?Color, null), col.cell_bg_pending);
}

test "cell bg: tableSetCellBgColor is a no-op without an active table" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    // Should not crash, should not allocate anything.
    u.tableSetCellBgColor(.{ .r = 255, .g = 0, .b = 0, .a = 255 });
    try expect(ctx.active_table == null);
}

test "cell bg: tableSetCellBgColor before tableNextColumn is a no-op" {
    // cur_col is -1 immediately after tableNextRow (no column entered
    // yet). Calling tableSetCellBgColor at that point must not
    // attempt to write to ts.columns[-1].
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{}));
    u.tableNextRow();
    // No tableNextColumn yet — cur_col is -1.
    u.tableSetCellBgColor(.{ .r = 100, .g = 100, .b = 100, .a = 255 });
    const ts: *TableState = &ctx.active_table.?;
    // No column got a pending color.
    try expectEqual(@as(?Color, null), ts.columns[0].cell_bg_pending);
    try expectEqual(@as(?Color, null), ts.columns[1].cell_bg_pending);
    u.endTable();
}

test "cell bg: tableSetCellBgColor stores the color on the current column" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{}));
    u.tableNextRow();
    _ = u.tableNextColumn(); // cur_col -> 0
    const c0: Color = .{ .r = 200, .g = 50, .b = 50, .a = 255 };
    u.tableSetCellBgColor(c0);
    const ts: *TableState = &ctx.active_table.?;
    try expectEqual(@as(?Color, c0), ts.columns[0].cell_bg_pending);
    try expectEqual(@as(?Color, null), ts.columns[1].cell_bg_pending);

    _ = u.tableNextColumn(); // cur_col -> 1
    const c1: Color = .{ .r = 50, .g = 200, .b = 50, .a = 255 };
    u.tableSetCellBgColor(c1);
    try expectEqual(@as(?Color, c0), ts.columns[0].cell_bg_pending);
    try expectEqual(@as(?Color, c1), ts.columns[1].cell_bg_pending);
    u.endTable();
}

test "cell bg: pending bg is flushed and reset on tableNextRow" {
    // Drive row 0 with a cell bg override. When row 1 begins via
    // tableNextRow, the row-finalization branch flushes the pending
    // override into splitter channel 0 and clears the field.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{}));
    u.tableNextRow();
    _ = u.tableNextColumn();
    const ts: *TableState = &ctx.active_table.?;
    const cell_color: Color = .{ .r = 80, .g = 90, .b = 100, .a = 255 };
    u.tableSetCellBgColor(cell_color);
    try expectEqual(@as(?Color, cell_color), ts.columns[0].cell_bg_pending);

    // Sanity: ensure the row has some height so the flushed bg has
    // a non-zero rect. Bump cursor_max so row_max_h grows on next
    // column advance.
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };

    const ch0_before: usize = ts.splitter.channels.items[0].cmds.items.len;
    u.tableNextRow();
    // After finalizing row 0, the pending bg has been painted into
    // channel 0 (one extra rect_filled cmd) and reset.
    try expect(ts.splitter.channels.items[0].cmds.items.len > ch0_before);
    try expectEqual(@as(?Color, null), ts.columns[0].cell_bg_pending);
    u.endTable();
}

test "cell bg: final row's pending bg is flushed at endTable" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{}));
    u.tableNextRow();
    _ = u.tableNextColumn();
    // Sentinel color — distinctive so the merge output can be probed.
    const cell_color: Color = .{ .r = 0xC0, .g = 0xFF, .b = 0xEE, .a = 255 };
    u.tableSetCellBgColor(cell_color);
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    // After endTable: the merged window draw_list contains the
    // sentinel rect_filled (packed 0xFFEEFFC0 — bgra → rgba
    // packing in colorToU32).
    const packed_sentinel: u32 = Color.toWire(cell_color);
    var found: bool = false;
    for (w.draw_list.cmds.items) |c| {
        if (c == .rect_filled and c.rect_filled.col == packed_sentinel) {
            found = true;
            break;
        }
    }
    try expect(found);
}

test "cell bg: alpha=0 cell color skips painting (clear-pending semantic)" {
    // Setting cell bg to transparent is the "I changed my mind; don't
    // paint anything for this cell" path. The pending field is set
    // (so future SetCellBgColor calls in the same cell override it),
    // but flushTableCellBgs skips alpha=0 colors at flush time.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    // No row_bg + no borders → channel 0 should have ZERO rect_filled
    // cmds when the only candidate (cell bg) is alpha=0 and skipped.
    try expect(u.beginTable("t", 1, .{ .row_bg = false, .borders = false }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    u.tableSetCellBgColor(.{ .r = 255, .g = 255, .b = 255, .a = 0 });
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    // After merge, the only thing the table could have added to
    // w.draw_list is bg-layer cmds (no widgets were submitted to
    // channel 1). With alpha=0 cell, no row bg, no borders, the
    // merged target should contain ZERO rect_filled cmds.
    var rect_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .rect_filled) {
            rect_count += 1;
        }
    }
    try expectEqual(@as(usize, 0), rect_count);
}

test "cell bg: cell override paints AFTER row bg in channel 0" {
    // Z-order contract: cell bgs are more specific than row bgs.
    // In channel 0's cmd stream, the row bg cmd must appear BEFORE
    // the cell bg cmd — so on replay the cell bg paints OVER the row
    // bg. (Both still land before channel 1's cell content after
    // the merge.)
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };
    ctx.style.table_row_bg = .{ .r = 32, .g = 32, .b = 32, .a = 255 };
    ctx.style.table_row_bg_alt = .{ .r = 64, .g = 64, .b = 64, .a = 255 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{ .row_bg = true }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    const cell_sentinel: Color = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC, .a = 255 };
    u.tableSetCellBgColor(cell_sentinel);
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    // Scan merged draw_list for first row-bg cmd (rgba 32,32,32,255
    // → packed 0xFF202020) and first cell-bg cmd (cell_sentinel).
    const packed_row_bg: u32 = Color.toWire(ctx.style.table_row_bg);
    const packed_cell_bg: u32 = Color.toWire(cell_sentinel);
    var row_idx: ?usize = null;
    var cell_idx: ?usize = null;
    for (w.draw_list.cmds.items, 0..) |c, i| {
        if (c != .rect_filled) {
            continue;
        }
        if (c.rect_filled.col == packed_row_bg and row_idx == null) {
            row_idx = i;
        }
        if (c.rect_filled.col == packed_cell_bg and cell_idx == null) {
            cell_idx = i;
        }
    }
    try expect(row_idx != null);
    try expect(cell_idx != null);
    // Row bg appears BEFORE cell bg → cell bg paints over row bg.
    try expect(row_idx.? < cell_idx.?);
}

// ----------------------------------------------------------------------------
// table borders_* family
// ----------------------------------------------------------------------------
// The single `borders: bool` master flag is now AND-gated with four
// granular sub-flags (borders_inner_h/outer_h/inner_v/outer_v). Each
// gate controls a specific subset of the border lines. Defaults:
// all four sub-flags `true`, master `borders` `true` — visually
// identical to pre-P8.2. Setting any individual sub-flag false
// suppresses just that subset. Setting `borders = false` still
// disables ALL borders for backward compat.

test "borders: defaults match pre-(master true, all granular true)" {
    const o: TableOpts = .{};
    try expect(o.borders);
    try expect(o.borders_inner_h);
    try expect(o.borders_outer_h);
    try expect(o.borders_inner_v);
    try expect(o.borders_outer_v);
}

test "borders: master `borders = false` overrides all granular flags" {
    // Even if every granular flag is true, master `borders = false`
    // suppresses all border drawing — backward compat with pre-P8.2.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{
        .borders = false, // master OFF
        .borders_inner_h = true, // ignored because master is off
        .borders_outer_h = true,
        .borders_inner_v = true,
        .borders_outer_v = true,
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    // No border lines (no rect_outline either — split into
    // per-side addLine cmds), no row bg, no cell bg → the merged
    // window draw_list has zero line cmds from the table.
    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    try expectEqual(@as(usize, 0), line_count);
}

test "borders: all granular true ⇒ outer rect emits 4 line cmds (per-side)" {
    // replaced the single `addRectOutline` cmd with four
    // independent `addLine` cmds so each side can be toggled
    // independently. Pin that representation: a fully-bordered
    // table with NO interior dividers (1 column, 1 row, no bg)
    // emits exactly 4 outer-edge lines.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    // 1 column → no inner_v dividers. 1 row → no inner_h
    // between-row dividers. No header → no header bottom line.
    // → Only the 4 outer-edge lines should remain.
    try expect(u.beginTable("t", 1, .{
        .borders = true,
        .borders_inner_h = false,
        .borders_outer_h = true,
        .borders_inner_v = false,
        .borders_outer_v = true,
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    try expectEqual(@as(usize, 4), line_count);
}

test "borders: borders_outer_h = false drops top + bottom outer lines" {
    // Suppress horizontal exterior; keep vertical exterior. A
    // 1-column, 1-row, no-bg, no-inner table should emit exactly
    // 2 lines (left + right outer edges).
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{
        .borders = true,
        .borders_inner_h = false,
        .borders_outer_h = false, // ← suppressed
        .borders_inner_v = false,
        .borders_outer_v = true,
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    // Only left + right outer edges → 2 lines.
    try expectEqual(@as(usize, 2), line_count);
}

test "borders: borders_outer_v = false drops left + right outer lines" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{
        .borders = true,
        .borders_inner_h = false,
        .borders_outer_h = true,
        .borders_inner_v = false,
        .borders_outer_v = false, // ← suppressed
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    // Only top + bottom outer edges → 2 lines.
    try expectEqual(@as(usize, 2), line_count);
}

test "borders: borders_inner_v = false drops column dividers" {
    // 3 columns → 2 interior vertical dividers WHEN borders_inner_v
    // is true. Set it false → 0 dividers, just the 4 outer lines.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 3, .{
        .borders = true,
        .borders_inner_h = false,
        .borders_outer_h = true,
        .borders_inner_v = false, // ← suppressed
        .borders_outer_v = true,
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    _ = u.tableNextColumn();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    // Just the 4 outer lines. Inner_v == false → no column dividers.
    try expectEqual(@as(usize, 4), line_count);
}

test "borders: borders_inner_v = true on a 3-col table adds 2 dividers" {
    // The positive test for the previous: same setup but with
    // inner_v = true. 4 outer + 2 dividers = 6 lines.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 3, .{
        .borders = true,
        .borders_inner_h = false,
        .borders_outer_h = true,
        .borders_inner_v = true, // ← on
        .borders_outer_v = true,
        .row_bg = false,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    _ = u.tableNextColumn();
    _ = u.tableNextColumn();
    ctx.current_window.?.layout.cursor_max = .{ 10, 30 };
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    try expectEqual(@as(usize, 6), line_count);
}

test "borders: borders_inner_h = false drops inter-row separators" {
    // 3 rows + inner_h true → 2 inter-row separators. inner_h false
    // → 0 inter-row separators. Outer borders also suppressed for a
    // clean line count.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{
        .borders = true,
        .borders_inner_h = false, // ← suppressed
        .borders_outer_h = false,
        .borders_inner_v = false,
        .borders_outer_v = false,
        .row_bg = false,
    }));
    var row: u32 = 0;
    while (row < 3) : (row += 1) {
        u.tableNextRow();
        _ = u.tableNextColumn();
        ctx.current_window.?.layout.cursor_max = .{ 10, 30 + float(row * 20) };
    }
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    try expectEqual(@as(usize, 0), line_count);
}

test "borders: borders_inner_h = true on 3 rows ⇒ 2 inter-row lines" {
    // Positive test for the previous: same setup, inner_h = true.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{
        .borders = true,
        .borders_inner_h = true, // ← on
        .borders_outer_h = false,
        .borders_inner_v = false,
        .borders_outer_v = false,
        .row_bg = false,
    }));
    var row: u32 = 0;
    while (row < 3) : (row += 1) {
        u.tableNextRow();
        _ = u.tableNextColumn();
        ctx.current_window.?.layout.cursor_max = .{ 10, 30 + float(row * 20) };
    }
    u.endTable();

    var line_count: usize = 0;
    for (w.draw_list.cmds.items) |c| {
        if (c == .line) {
            line_count += 1;
        }
    }
    // 3 rows → tableNextRow fires the "finalize previous row" branch
    // 2 times (for rows 0 and 1), emitting 2 inter-row lines. Row 2's
    // finalize doesn't draw inter-row (it's the last row — handled in
    // endTable, which doesn't emit an inter-row line for the final).
    try expectEqual(@as(usize, 2), line_count);
}

// ----------------------------------------------------------------------------
// table padding flags
// ----------------------------------------------------------------------------
// `no_pad_outer_x` and `no_pad_inner_x` toggle the horizontal cell
// padding zimr applies to a column's left edge:
//   - column 0: gated by no_pad_outer_x (outer-left gap).
//   - columns 1..N-1: gated by no_pad_inner_x (gap to the divider
//     on the left).
// Defaults are both false → cursor sits at `col.x + cell_padding_x`
// for every column (unchanged from pre-P8.3). Setting either flag
// to true skips the padding for the relevant column subset.

test "padding: TableOpts defaults — both no_pad_* are false" {
    const o: TableOpts = .{};
    try expect(!o.no_pad_outer_x);
    try expect(!o.no_pad_inner_x);
}

test "padding: default places col-0 cursor at col.x + cell_padding_x" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{
        .cell_padding_x = 8,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    const ts: *TableState = &ctx.active_table.?;
    // col[0].x is the table's outer-left edge. Cursor adds
    // cell_padding_x (default).
    try expectEqual(ts.columns[0].x + 8, ctx.current_window.?.layout.cursor_pos[0]);
    u.endTable();
}

test "padding: no_pad_outer_x makes col-0 cursor flush against outer-left" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{
        .cell_padding_x = 8,
        .no_pad_outer_x = true,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    const ts: *TableState = &ctx.active_table.?;
    // Cursor at col[0].x exactly — no left padding applied.
    try expectEqual(ts.columns[0].x, ctx.current_window.?.layout.cursor_pos[0]);
    u.endTable();
}

test "padding: no_pad_outer_x does NOT affect col-1 (inner column)" {
    // Only the OUTER column (col 0) loses padding; inner columns
    // keep theirs.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 2, .{
        .cell_padding_x = 8,
        .no_pad_outer_x = true,
        .no_pad_inner_x = false, // inner columns keep padding
    }));
    u.tableNextRow();
    _ = u.tableNextColumn(); // col 0 — flush
    _ = u.tableNextColumn(); // col 1 — should still have left padding
    const ts: *TableState = &ctx.active_table.?;
    try expectEqual(ts.columns[1].x + 8, ctx.current_window.?.layout.cursor_pos[0]);
    u.endTable();
}

test "padding: no_pad_inner_x makes col-1+ cursors flush against divider" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 3, .{
        .cell_padding_x = 8,
        .no_pad_outer_x = false, // col 0 keeps its outer-left padding
        .no_pad_inner_x = true, // col 1, 2 lose theirs
    }));
    u.tableNextRow();
    _ = u.tableNextColumn(); // col 0
    const ts: *TableState = &ctx.active_table.?;
    try expectEqual(ts.columns[0].x + 8, ctx.current_window.?.layout.cursor_pos[0]);
    _ = u.tableNextColumn(); // col 1
    try expectEqual(ts.columns[1].x, ctx.current_window.?.layout.cursor_pos[0]);
    _ = u.tableNextColumn(); // col 2
    try expectEqual(ts.columns[2].x, ctx.current_window.?.layout.cursor_pos[0]);
    u.endTable();
}

test "padding: both flags true ⇒ every column cursor flush against its left edge" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 3, .{
        .cell_padding_x = 8,
        .no_pad_outer_x = true,
        .no_pad_inner_x = true,
    }));
    u.tableNextRow();
    const ts: *TableState = &ctx.active_table.?;
    var i: u32 = 0;
    while (i < 3) : (i += 1) {
        _ = u.tableNextColumn();
        try expectEqual(ts.columns[i].x, ctx.current_window.?.layout.cursor_pos[0]);
    }
    u.endTable();
}

test "padding: vertical cell_padding_y is unaffected by either flag" {
    // The flags only touch horizontal padding. Vertical padding
    // (cell_padding_y, applied to cursor_y) should remain.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 1, .{
        .cell_padding_x = 8,
        .cell_padding_y = 5,
        .no_pad_outer_x = true,
        .no_pad_inner_x = true,
    }));
    u.tableNextRow();
    _ = u.tableNextColumn();
    const ts: *TableState = &ctx.active_table.?;
    // Vertical position still uses cell_padding_y.
    try expectEqual(ts.row_y + 5, ctx.current_window.?.layout.cursor_pos[1]);
    u.endTable();
}

test "computeTableColumnLayout: fixed columns get user width, stretch share the remainder" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var ts: TableState = .{
        .id = 0,
        .n_columns = 3,
        .columns_set_up = 0,
        .columns = @splat(.{}),
        .row_y = 0,
        .row_max_h = 0,
        .cur_col = -1,
        .cur_row = -1,
        .opts = .{},
        .outer_rect = .{ .x = 0, .y = 0, .width = 300, .height = 0 },
        .has_setup = true,
        .scroll_y = 0,
        .data_origin_y = 0,
        .clip_pushed = false,
        .row_bg_override = null,
    };
    // Column 0: fixed 80px. Column 1: stretch weight 1. Column 2:
    // stretch weight 2. Remainder = 300 - 80 = 220 shared 1:2 →
    // ~73.33 and ~146.67.
    ts.columns[0].sizing = .fixed;
    ts.columns[0].user_width = 80;
    ts.columns[1].sizing = .stretch;
    ts.columns[1].user_weight = 1;
    ts.columns[2].sizing = .stretch;
    ts.columns[2].user_weight = 2;
    ts.columns_set_up = 3;

    computeTableColumnLayout(&ctx, &ts);

    try expectApproxEqAbs(@as(f32, 80), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 220.0 / 3.0), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 440.0 / 3.0), ts.columns[2].width, 1e-3);
    // X positions stack left-to-right.
    try expectApproxEqAbs(@as(f32, 0), ts.columns[0].x, 1e-3);
    try expectApproxEqAbs(@as(f32, 80), ts.columns[1].x, 1e-3);
    try expectApproxEqAbs(@as(f32, 80 + 220.0 / 3.0), ts.columns[2].x, 1e-3);
}

test "computeTableColumnLayout: missing setup columns default to stretch" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var ts: TableState = .{
        .id = 0,
        .n_columns = 2,
        .columns_set_up = 0,
        .columns = @splat(.{}),
        .row_y = 0,
        .row_max_h = 0,
        .cur_col = -1,
        .cur_row = -1,
        .opts = .{},
        .outer_rect = .{ .x = 10, .y = 0, .width = 200, .height = 0 },
        .has_setup = false,
        .scroll_y = 0,
        .data_origin_y = 0,
        .clip_pushed = false,
        .row_bg_override = null,
    };
    // No tableSetupColumn calls - both columns default to stretch
    // weight 1, sharing 200px equally.
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 100), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 100), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 10), ts.columns[0].x, 1e-3);
    try expectApproxEqAbs(@as(f32, 110), ts.columns[1].x, 1e-3);
}

// ---- sizing-mode suite -----------------------------
//

// Each test builds a TableState directly with measured `width_auto_seen`
// values (simulating the previous-frame measurement state) and asserts
// `computeTableColumnLayout` produces the expected per-column widths.
//

// The factory keeps boilerplate down: three measured columns, the
// caller picks the table-level policy.

fn makeP84TableState(sizing: TableSizing, outer_w: f32) TableState {
    var ts: TableState = std.mem.zeroes(TableState);
    ts.id = 0xCAFEBABE;
    ts.n_columns = 3;
    ts.columns_set_up = 3; // pretend three setup calls already ran
    ts.cur_row = -1;
    ts.cur_col = -1;
    ts.outer_rect = .{ .x = 0, .y = 0, .width = outer_w, .height = 0 };
    ts.opts = .{ .sizing = sizing };
    // Three columns with distinct measured widths. The values are
    // chosen so the four policies produce visibly-different outputs.
    ts.columns[0] = .{ .sizing = defaultColumnSizing(sizing), .width_auto_seen = 30 };
    ts.columns[1] = .{ .sizing = defaultColumnSizing(sizing), .width_auto_seen = 60 };
    ts.columns[2] = .{ .sizing = defaultColumnSizing(sizing), .width_auto_seen = 90 };
    return ts;
}

test "P8.4: stretch_same — equal width regardless of content" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Three stretch columns, 300px outer → 100px each. Measured
    // widths (30 / 60 / 90) are ignored because the policy
    // distributes EQUALLY when no per-column weights are set.
    var ts: TableState = makeP84TableState(.stretch_same, 300);
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 100), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 100), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 100), ts.columns[2].width, 1e-3);
}

test "P8.4: stretch_prop — widths proportional to measured content" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Three stretch columns with content weights 30/60/90 (sum 180),
    // 360px outer → 60/120/180px. No per-column weight override, so
    // the policy supplies weights from `width_auto_seen`.
    var ts: TableState = makeP84TableState(.stretch_prop, 360);
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 60), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 120), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 180), ts.columns[2].width, 1e-3);
}

test "P8.4: fixed_fit — each column = its own measured content width" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Each column gets its own width_auto_seen value. outer is
    // wider than the sum (180) so no clamping interferes.
    var ts: TableState = makeP84TableState(.fixed_fit, 400);
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 30), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 60), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[2].width, 1e-3);
}

test "P8.4: fixed_same — every fixed column = max measured width" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Max measured width is 90 (column 2). All three columns get 90.
    var ts: TableState = makeP84TableState(.fixed_same, 400);
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[2].width, 1e-3);
}

test "P8.4: per-column override beats table policy (user weight wins)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Policy is stretch_prop (content-driven weights) but we pin
    // column 1 to an explicit weight of 4. Policy gives col 0 and
    // col 2 their content-derived weights (30 and 90); col 1's 4
    // wins over its content-derived value (60). Sum = 30 + 4 + 90
    // = 124. outer 620 → 30 * 5 = 150, 4 * 5 = 20, 90 * 5 = 450.
    var ts: TableState = makeP84TableState(.stretch_prop, 620);
    ts.columns[1].user_weight = 4;
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 150), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 20), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 450), ts.columns[2].width, 1e-3);
}

test "P8.4: per-column override beats table policy (user width wins)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Policy is fixed_same (every fixed column = max measured = 90)
    // but column 0 has user_width = 25 which always wins.
    var ts: TableState = makeP84TableState(.fixed_same, 400);
    ts.columns[0].user_width = 25;
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 25), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[1].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 90), ts.columns[2].width, 1e-3);
}

test "P8.4: defaultColumnSizing maps table policy to column sizing" {
    try expectEqual(TableColumnSizing.stretch, defaultColumnSizing(.stretch_same));
    try expectEqual(TableColumnSizing.stretch, defaultColumnSizing(.stretch_prop));
    try expectEqual(TableColumnSizing.fixed, defaultColumnSizing(.fixed_fit));
    try expectEqual(TableColumnSizing.fixed, defaultColumnSizing(.fixed_same));
}

test "P8.4: first-frame fallback when width_auto_seen is 0" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    // Pin font_size to 10 so the fallback-formula assertion is
    // independent of the desktop-default `Style.font_size` (which
    // changed from 10 → 16 in the size-default bump, breaking the
    // `60 = 10 * 6` value tied to this test).
    ctx.style.font_size = 10;
    // font_size = 10 → fallback = 60 per column.
    try expectEqual(@as(f32, 60), fallbackColumnWidth(ctx.style.font_size));

    // fixed_fit with zero measurement → each column = fallback = 60.
    var ts: TableState = std.mem.zeroes(TableState);
    ts.n_columns = 2;
    ts.columns_set_up = 0; // forces inheritance fill
    ts.cur_row = -1;
    ts.cur_col = -1;
    ts.outer_rect = .{ .x = 0, .y = 0, .width = 400, .height = 0 };
    ts.opts = .{ .sizing = .fixed_fit };
    computeTableColumnLayout(&ctx, &ts);
    try expectApproxEqAbs(@as(f32, 60), ts.columns[0].width, 1e-3);
    try expectApproxEqAbs(@as(f32, 60), ts.columns[1].width, 1e-3);
}

test "P8.4: width_auto cache round-trips through endTable→beginTable" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Set up a fake TableState with measured widths and persist.
    var ts: TableState = std.mem.zeroes(TableState);
    ts.id = 0xBEEF;
    ts.n_columns = 3;
    ts.columns[0].width_auto_seen = 42;
    ts.columns[1].width_auto_seen = 73;
    ts.columns[2].width_auto_seen = 19;
    persistTableWidthAuto(&ctx, &ts);

    // Cache must now have the three values stored against id 0xBEEF.
    const cached: [max_table_columns]f32 = ctx.table_width_auto_cache.get(0xBEEF) orelse
        return error.CacheMissing;
    try expectEqual(@as(f32, 42), cached[0]);
    try expectEqual(@as(f32, 73), cached[1]);
    try expectEqual(@as(f32, 19), cached[2]);
}

test "table: snapshotCellHeight captures the last cell of each row" {
    // Regression test for the latent bug spotted while implementing
    // P8.4: tableNextColumnImpl only snapshotted height
    // when ADVANCING between cells, so the LAST cell of every row
    // was never measured. Symptom: a row whose tallest cell was
    // the rightmost column rendered with row_max_h reflecting only
    // cells 0..N-2.
    //

    // We simulate the situation directly with a TableState rather
    // than driving it through the full Ui/Window machinery: set
    // cur_col to the last column, push cursor_max[1] down to mimic
    // a tall widget, call snapshotCellHeight, and verify row_max_h
    // got bumped. This is what tableNextRow / endTable now do
    // for the last cell.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var w: Window = std.mem.zeroes(Window);
    var ts: TableState = std.mem.zeroes(TableState);
    ts.n_columns = 3;
    ts.cur_col = 2; // simulate "inside the last column"
    ts.cur_row = 0;
    ts.row_y = 100;
    ts.row_max_h = 20; // cells 0 + 1 had height 20
    ts.opts = .{ .cell_padding_y = 2 };
    // Simulate a widget that wrote to y = 100 + 50 = 150 — a tall
    // cell whose used height (150 - 100) + cell_padding_y = 52
    // should overtake the existing row_max_h of 20.
    w.layout.cursor_max = .{ 0, 150 };
    snapshotCellHeight(&ts, &w);
    try expectApproxEqAbs(@as(f32, 52), ts.row_max_h, 1e-3);
}

test "table: snapshotCellHeight is a no-op when no active column" {
    // Guard against the "cur_col == -1 between tableNextRow and the
    // first tableNextColumn" state — calling the helper there
    // would have to do nothing rather than misread row state.
    var w: Window = std.mem.zeroes(Window);
    var ts: TableState = std.mem.zeroes(TableState);
    ts.cur_col = -1;
    ts.row_max_h = 7;
    w.layout.cursor_max = .{ 0, 9999 }; // would normally bump row_max_h
    snapshotCellHeight(&ts, &w);
    // row_max_h unchanged because cur_col < 0 short-circuits.
    try expectEqual(@as(f32, 7), ts.row_max_h);
}

test "cycleTableSort: plain click on new column → primary ascending" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &default_col);
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 1), ss.len);
    try expectEqual(@as(u32, 1), ss.specs[0].column_index);
    try expectEqual(TableSortDirection.ascending, ss.specs[0].direction);
}

test "cycleTableSort: plain click on the primary flips direction" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &default_col);
    cycleTableSort(&ctx, id, 1, false, &default_col); // re-click → flip
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 1), ss.len);
    try expectEqual(TableSortDirection.descending, ss.specs[0].direction);
    cycleTableSort(&ctx, id, 1, false, &default_col);
    try expectEqual(TableSortDirection.ascending, ss.specs[0].direction);
}

test "cycleTableSort: plain click on a different column promotes it, wipes others" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, true, &default_col); // start
    cycleTableSort(&ctx, id, 2, true, &default_col); // additive
    var ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 2), ss.len);
    // Now plain-click column 3 - should drop to single-column.
    cycleTableSort(&ctx, id, 3, false, &default_col);
    ss = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 1), ss.len);
    try expectEqual(@as(u32, 3), ss.specs[0].column_index);
}

test "cycleTableSort: shift-click on new column appends as secondary" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &default_col);
    cycleTableSort(&ctx, id, 2, true, &default_col);
    cycleTableSort(&ctx, id, 3, true, &default_col);
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 3), ss.len);
    try expectEqual(@as(u32, 1), ss.specs[0].column_index);
    try expectEqual(@as(u32, 2), ss.specs[1].column_index);
    try expectEqual(@as(u32, 3), ss.specs[2].column_index);
}

test "cycleTableSort: 4th shift-click rotates - old tertiary falls off" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &default_col);
    cycleTableSort(&ctx, id, 2, true, &default_col);
    cycleTableSort(&ctx, id, 3, true, &default_col);
    cycleTableSort(&ctx, id, 4, true, &default_col); // rotate: 4 primary, 1 secondary, 2 tertiary, 3 off
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 3), ss.len);
    try expectEqual(@as(u32, 4), ss.specs[0].column_index);
    try expectEqual(@as(u32, 1), ss.specs[1].column_index);
    try expectEqual(@as(u32, 2), ss.specs[2].column_index);
}

test "cycleTableSort: shift-click on existing column flips its direction in place" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var default_col: TableColumnState = .{};
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &default_col);
    cycleTableSort(&ctx, id, 2, true, &default_col);
    cycleTableSort(&ctx, id, 2, true, &default_col); // re-shift-click on secondary
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 2), ss.len);
    try expectEqual(TableSortDirection.descending, ss.specs[1].direction);
    // Primary untouched.
    try expectEqual(TableSortDirection.ascending, ss.specs[0].direction);
}

// ----------------------------------------------------------------
// P8.6 part 1: sort suppression flags
// ----------------------------------------------------------------

test "P8.6: no_sort_ascending — initial click starts at descending" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var col: TableColumnState = .{ .no_sort_ascending = true };
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &col);
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(@as(u8, 1), ss.len);
    try expectEqual(TableSortDirection.descending, ss.specs[0].direction);
}

test "P8.6: no_sort_ascending — re-click on primary stays at descending (can't flip back)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var col: TableColumnState = .{ .no_sort_ascending = true };
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &col); // descending
    cycleTableSort(&ctx, id, 1, false, &col); // would flip to asc, but asc is blocked → stays desc
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(TableSortDirection.descending, ss.specs[0].direction);
}

test "P8.6: no_sort_descending — initial click is ascending, re-click stays" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var col: TableColumnState = .{ .no_sort_descending = true };
    const id: Id = 42;
    cycleTableSort(&ctx, id, 1, false, &col); // ascending
    cycleTableSort(&ctx, id, 1, false, &col); // would flip to desc, blocked → stays asc
    const ss: *TableSortSpecs = ctx.table_sort_state.getPtr(id).?;
    try expectEqual(TableSortDirection.ascending, ss.specs[0].direction);
}

test "P8.6: default_sort seeds initial sort specs at column setup" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
    defer w.close();

    if (ui_handle.beginTable("t", 3, .{})) {
        defer ui_handle.endTable();
        ui_handle.tableSetupColumn("col0", .{});
        ui_handle.tableSetupColumn("col1", .{ .default_sort = .descending });
        ui_handle.tableSetupColumn("col2", .{});
    }

    // Find the table's id.  beginTable hashes the table label
    // under the window's id stack — easiest to find the live
    // table id is `active_table.id`, but that's nulled at
    // endTable.  Walk the persisted sort_state map instead:
    // there should be exactly one entry, the table we just
    // submitted.
    var it: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Iterator = ctx.table_sort_state.iterator();
    const entry: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Entry = it.next() orelse return error.MissingSortSpec;
    const ss: *TableSortSpecs = entry.value_ptr;
    try expectEqual(@as(u8, 1), ss.len);
    try expectEqual(@as(u32, 1), ss.specs[0].column_index);
    try expectEqual(TableSortDirection.descending, ss.specs[0].direction);
}

test "P8.6: default_sort does NOT re-seed once the user has interacted" {
    // Same setup twice; between submissions, simulate a user
    // click moving the primary to a different column.  The second
    // submission must NOT clobber the user's choice.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: submit, default_sort lands on col 1 ascending.
    {
        const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
        defer w.close();
        if (ui_handle.beginTable("t", 3, .{})) {
            defer ui_handle.endTable();
            ui_handle.tableSetupColumn("col0", .{});
            ui_handle.tableSetupColumn("col1", .{ .default_sort = .ascending });
            ui_handle.tableSetupColumn("col2", .{});
        }
    }

    // "User clicks col2."  Find the table's id (only entry in
    // the sort map), then directly mutate the specs as the click
    // handler would.
    var it: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Iterator = ctx.table_sort_state.iterator();
    const entry: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Entry = it.next() orelse return error.MissingSortSpec;
    const table_id: Id = entry.key_ptr.*;
    var col2: TableColumnState = .{};
    cycleTableSort(&ctx, table_id, 2, false, &col2);
    try expectEqual(@as(u32, 2), entry.value_ptr.specs[0].column_index);

    // Frame 2: same submission.  default_sort on col 1 must NOT
    // re-fire because the spec already exists.
    {
        const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
        defer w.close();
        if (ui_handle.beginTable("t", 3, .{})) {
            defer ui_handle.endTable();
            ui_handle.tableSetupColumn("col0", .{});
            ui_handle.tableSetupColumn("col1", .{ .default_sort = .ascending });
            ui_handle.tableSetupColumn("col2", .{});
        }
    }

    // Primary still col 2 — user's click was preserved.
    var it2: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Iterator = ctx.table_sort_state.iterator();
    const entry2: std.AutoHashMapUnmanaged(Id, TableSortSpecs).Entry = it2.next() orelse return error.MissingSortSpec;
    try expectEqual(@as(u32, 2), entry2.value_ptr.specs[0].column_index);
}

test "P8.6: no_sort opt flows from TableColumnOpts into TableColumnState" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
    defer w.close();

    _ = ui_handle.beginTable("t", 2, .{});
    defer ui_handle.endTable();
    ui_handle.tableSetupColumn("a", .{});
    ui_handle.tableSetupColumn("b", .{ .no_sort = true });

    const ts: *TableState = if (ctx.active_table) |*t| t else return error.NoTable;
    try expect(!ts.columns[0].no_sort);
    try expect(ts.columns[1].no_sort);
}

test "P8.6: width_auto resolves to sizing=.fixed with user_width=0" {
    // `width_auto` is a setup-time shortcut for "fit content."
    // Resolution: state.sizing → .fixed; state.user_width → 0;
    // downstream layout reads `width_auto_seen` for the actual
    // value.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
    defer w.close();

    _ = ui_handle.beginTable("t", 2, .{});
    defer ui_handle.endTable();
    ui_handle.tableSetupColumn("auto", .{ .width_auto = true });
    ui_handle.tableSetupColumn("stretchy", .{ .sizing = .stretch });

    const ts: *TableState = if (ctx.active_table) |*t| t else return error.NoTable;
    try expectEqual(TableColumnSizing.fixed, ts.columns[0].sizing);
    try expectEqual(@as(f32, 0), ts.columns[0].user_width);
    try expectEqual(TableColumnSizing.stretch, ts.columns[1].sizing);
}

test "P8.6: width_auto wins over explicit sizing and width" {
    // The caller's intent when setting width_auto is unambiguous:
    // fit content.  Any conflicting .sizing or .width on the same
    // opts is ignored — width_auto overrides both.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
    defer w.close();

    _ = ui_handle.beginTable("t", 1, .{});
    defer ui_handle.endTable();
    // Set conflicting hints: explicit width 200 + stretch sizing,
    // BUT width_auto = true.  The flag wins.
    ui_handle.tableSetupColumn("conflicty", .{
        .sizing = .stretch,
        .width = 200,
        .width_auto = true,
    });

    const ts: *TableState = if (ctx.active_table) |*t| t else return error.NoTable;
    try expectEqual(TableColumnSizing.fixed, ts.columns[0].sizing);
    try expectEqual(@as(f32, 0), ts.columns[0].user_width);
}

test "P8.6: no_header_label flows from TableColumnOpts into TableColumnState" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui_handle: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const w: WindowHandle = ui_handle.window("w", .{}) orelse unreachable;
    defer w.close();

    _ = ui_handle.beginTable("t", 2, .{});
    defer ui_handle.endTable();
    ui_handle.tableSetupColumn("labeled", .{});
    ui_handle.tableSetupColumn("hidden", .{ .no_header_label = true });

    const ts: *TableState = if (ctx.active_table) |*t| t else return error.NoTable;
    try expect(!ts.columns[0].no_header_label);
    try expect(ts.columns[1].no_header_label);
}

test "P8.8: tableGetColumnCount + Index reflect setup + iteration state" {
    // getColumnCount mirrors `beginTable`'s `n_columns` arg.
    // getColumnIndex returns null until the first tableNextColumn,
    // then tracks the current cell's column 0-based.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    // No active table → both queries return null.
    try expectEqual(@as(?u32, null), u.tableGetColumnCount());
    try expectEqual(@as(?u32, null), u.tableGetColumnIndex());

    try expect(u.beginTable("t", 3, .{}));
    try expectEqual(@as(?u32, 3), u.tableGetColumnCount());
    // Inside a table, before any tableNextColumn, cur_col is -1.
    try expectEqual(@as(?u32, null), u.tableGetColumnIndex());
    u.tableNextRow();
    try expectEqual(@as(?u32, null), u.tableGetColumnIndex());
    _ = u.tableNextColumn();
    try expectEqual(@as(?u32, 0), u.tableGetColumnIndex());
    _ = u.tableNextColumn();
    try expectEqual(@as(?u32, 1), u.tableGetColumnIndex());
    _ = u.tableNextColumn();
    try expectEqual(@as(?u32, 2), u.tableGetColumnIndex());
    u.endTable();
    // After endTable, no active table again.
    try expectEqual(@as(?u32, null), u.tableGetColumnCount());
}

test "P8.8: tableGetColumnName returns the configured label per column" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 3, .{}));
    defer u.endTable();
    u.tableSetupColumn("Name", .{});
    u.tableSetupColumn("Age", .{});
    u.tableSetupColumn("", .{}); // empty label → empty slice (not null)

    try expectEqualStrings("Name", u.tableGetColumnName(0).?);
    try expectEqualStrings("Age", u.tableGetColumnName(1).?);
    try expectEqualStrings("", u.tableGetColumnName(2).?);
    // Index out of range → null.
    try expectEqual(@as(?[]const u8, null), u.tableGetColumnName(3));
    try expectEqual(@as(?[]const u8, null), u.tableGetColumnName(99));
}

test "P8.8: tableGetColumnFlags reconstructs the configured opts" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 400, 300 }, .draw_list = .{} };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    ctx.current_draw_list = &w.draw_list;
    w.layout.cursor_pos = .{ 10, 10 };
    w.layout.work_rect_max = .{ 400, 300 };

    const u: Ui = .{ .ctx = &ctx };
    try expect(u.beginTable("t", 4, .{}));
    defer u.endTable();
    u.tableSetupColumn("a", .{});
    u.tableSetupColumn("b", .{ .no_sort = true, .no_header_label = true });
    u.tableSetupColumn("c", .{ .no_sort_ascending = true, .no_sort_descending = true });
    u.tableSetupColumn("d", .{ .default_sort = .descending });

    const f0: TableColumnFlags = u.tableGetColumnFlags(0).?;
    try expect(!f0.no_sort);
    try expect(!f0.no_header_label);
    try expectEqual(@as(?TableSortDirection, null), f0.default_sort);

    const f1: TableColumnFlags = u.tableGetColumnFlags(1).?;
    try expect(f1.no_sort);
    try expect(f1.no_header_label);

    const f2: TableColumnFlags = u.tableGetColumnFlags(2).?;
    try expect(f2.no_sort_ascending);
    try expect(f2.no_sort_descending);

    const f3: TableColumnFlags = u.tableGetColumnFlags(3).?;
    try expectEqual(@as(?TableSortDirection, .descending), f3.default_sort);

    // Out-of-range index → null.
    try expectEqual(@as(?TableColumnFlags, null), u.tableGetColumnFlags(4));
}

test "table scroll state: persisted scroll_y survives across frames" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Simulate the put/get cycle the impl does in endTable / beginTable.
    const id: Id = 7;
    try ctx.table_scroll_state.put(ctx.gpa, id, 42.5);
    const got: f32 = ctx.table_scroll_state.get(id).?;
    try expectEqual(@as(f32, 42.5), got);
}

test "TableOpts.outer_height: zero means auto-fit (no scroll)" {
    // Documents the "0 = auto-fit" convention exposed by the public
    // API. No widget call needed; just verifies the default.
    const opts: TableOpts = .{};
    try expectEqual(@as(f32, 0), opts.outer_height);
    try expectEqual(@as(u32, 0), opts.freeze_rows);
}

test "TableOpts.row_bg: defaults to false (no stripes by default)" {
    const opts: TableOpts = .{};
    try expectEqual(false, opts.row_bg);
}

test "Style.table_row_bg / table_row_bg_alt: dark default values" {
    const s: Style = Style.dark_default;
    // Even rows fully transparent - bg shows through.
    try expectEqual(@as(u8, 0), s.table_row_bg.a);
    // Odd rows: faint white tint, low alpha.
    try expectEqual(@as(u8, 255), s.table_row_bg_alt.r);
    try expect(s.table_row_bg_alt.a > 0 and s.table_row_bg_alt.a < 64);
}

test "tableSetRowBgColor: no-op without an active table" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    u.tableSetRowBgColor(.{ .r = 255, .g = 0, .b = 0, .a = 255 });
    try expect(ctx.active_table == null);
}

// ============================================================================
// Tests: drag-drop source ()
// ============================================================================

test "DragDropState: starts idle with no payload" {
    const dd: DragDropState = .{};
    try expectEqual(DragDropState.Phase.idle, dd.phase);
    try expectEqual(@as(usize, 0), dd.type_name_len);
    try expectEqual(@as(usize, 0), dd.payload_len);
}

test "setDragDropPayload: stores @typeName + bytes for active drag" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Force the state into active manually (in production this
    // happens via the begin* edge logic).
    ctx.drag_drop.phase = .active;

    const PayloadT = struct { build_id: u32, ok: bool };
    const value: PayloadT = .{ .build_id = 1234, .ok = true };
    setDragDropPayloadImpl(&ctx, PayloadT, &value);

    // Type tag stored.
    const tn: []const u8 = ctx.drag_drop.typeName();
    try expect(tn.len > 0);
    try expect(std.mem.indexOf(u8, tn, "PayloadT") != null);

    // Payload bytes round-trip via memcpy into a properly-aligned
    // local - same pattern target-side code uses with
    // acceptDragDropPayload (). Direct @ptrCast off the
    // u8 buffer would be alignment-UB.
    var back: PayloadT = undefined;
    @memcpy(std.mem.asBytes(&back), ctx.drag_drop.payload_buf[0..@sizeOf(PayloadT)]);
    try expectEqual(@as(u32, 1234), back.build_id);
    try expectEqual(true, back.ok);
}

test "setDragDropPayload: no-op when drag is idle" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Phase stays idle - call should be silently dropped.
    const v: u32 = 42;
    setDragDropPayloadImpl(&ctx, u32, &v);
    try expectEqual(@as(usize, 0), ctx.drag_drop.type_name_len);
    try expectEqual(@as(usize, 0), ctx.drag_drop.payload_len);
}

test "setDragDropPayload: oversize types are rejected (no type tag)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.drag_drop.phase = .active;
    // 512 bytes > PAYLOAD_CAP (256).
    const Big = [512]u8;
    var huge: Big = undefined;
    huge[0] = 0xAA;
    setDragDropPayloadImpl(&ctx, Big, &huge);
    // Rejected: type tag cleared so no target will match.
    try expectEqual(@as(usize, 0), ctx.drag_drop.type_name_len);
    try expectEqual(@as(usize, 0), ctx.drag_drop.payload_len);
}

test "beginDragDropSource: returns false without an active window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(false, u.beginDragDropSource(.{}));
}

test "acceptDragDropPayload: returns null outside a target scope" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // No target scope opened.
    const got: ?u32 = acceptDragDropPayloadImpl(&ctx, u32, .{});
    try expectEqual(@as(?u32, null), got);
}

test "acceptDragDropPayload: returns null without an active drag" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Pretend a target scope is open, but drag is idle.
    ctx.drag_drop.target_widget_id = 99;
    const got: ?u32 = acceptDragDropPayloadImpl(&ctx, u32, .{});
    try expectEqual(@as(?u32, null), got);
}

test "acceptDragDropPayload: type mismatch returns null" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.drag_drop.phase = .active;
    ctx.drag_drop.target_widget_id = 99;

    // Set a payload of type u32.
    const v: u32 = 42;
    setDragDropPayloadImpl(&ctx, u32, &v);
    // Release edge.
    ctx.input.mouse_left_released = true;

    // Asking for f32 → type mismatch → null.
    const wrong: ?f32 = acceptDragDropPayloadImpl(&ctx, f32, .{});
    try expectEqual(@as(?f32, null), wrong);
    // Asking for the right type → success.
    const right: ?u32 = acceptDragDropPayloadImpl(&ctx, u32, .{});
    try expectEqual(@as(?u32, 42), right);
}

test "acceptDragDropPayload: only delivers on the release edge" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.drag_drop.phase = .active;
    ctx.drag_drop.target_widget_id = 99;

    const Pld = struct { n: u32 };
    const v: Pld = .{ .n = 7 };
    setDragDropPayloadImpl(&ctx, Pld, &v);

    // No release edge yet - null.
    ctx.input.mouse_left_released = false;
    try expectEqual(@as(?Pld, null), acceptDragDropPayloadImpl(&ctx, Pld, .{}));

    // Release edge → delivered.
    ctx.input.mouse_left_released = true;
    const got = acceptDragDropPayloadImpl(&ctx, Pld, .{});
    try expect(got != null);
    try expectEqual(@as(u32, 7), got.?.n);
}

test "accept_before_delivery returns payload mid-drag (no release)" {
    // The new flag lets a target peek the payload while the mouse
    // is still held down. Useful for "is this droppable here?"
    // visual feedback that updates as the user drags.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.drag_drop.phase = .active;
    ctx.drag_drop.target_widget_id = 99;

    const Pld = struct { n: u32 };
    const v: Pld = .{ .n = 42 };
    setDragDropPayloadImpl(&ctx, Pld, &v);

    // Mouse STILL held.
    ctx.input.mouse_left_released = false;

    // Without the flag → null (release-edge gated).
    try expectEqual(@as(?Pld, null), acceptDragDropPayloadImpl(&ctx, Pld, .{}));

    // With.accept_before_delivery → returns payload mid-drag.
    const peek: ?Pld = acceptDragDropPayloadImpl(&ctx, Pld, .{ .accept_before_delivery = true });
    try expect(peek != null);
    try expectEqual(@as(u32, 42), peek.?.n);
}

test "accept_peek_only convenience combines flags" {
    // Convenience constant should equal { accept_before_delivery, accept_no_draw_default_rect }.
    const combo_flags: DragDropFlags = DragDropFlags.accept_peek_only;
    try expect(combo_flags.accept_before_delivery);
    try expect(combo_flags.accept_no_draw_default_rect);
    try expect(!combo_flags.source_no_preview);
    try expect(!combo_flags.accept_draw_as_hovered);
}

test "pushItemFlag .disabled suppresses button click" {
    // The new stack-based path should compose with the existing
    // beginDisabled machinery. Verify by pushing disabled, then
    // confirming a synthesized click is ignored.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 400,
        .canvas_h = 300,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    var btn_center: Vec2 = .{ 0, 0 };
    // Frame 1: lay out the button (no click), capture rect.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .disabled = true });
        defer ui.popItemFlag();
        // Button submitted in a disabled scope. Cursor away - no click.
        const clicked: bool = ui.button("Go", .{});
        try expect(!clicked);
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        btn_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }
    // Frame 2: synthesize a click ON the button - disabled scope
    // should suppress it.
    {
        const snapshot: InputSnapshot = .{
            .mouse_pos = btn_center,
            .mouse_left_clicked = true,
            .mouse_left_down = true,
            .mouse_left_released = false,
        };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .disabled = true });
        defer ui.popItemFlag();
        // Click on the button. Disabled scope must eat it.
        const clicked: bool = ui.button("Go", .{});
        try expect(!clicked);
        try expect(ui.isDisabled());
    }
}

test "mergeItemFlags OR semantics for bool flags" {
    // disabled / button_repeat / allow_overlap / no_tab_stop OR
    // up the stack; auto_close_popups ANDs (parent's disable wins).
    const parent: ItemFlags = .{
        .disabled = true,
        .button_repeat = false,
        .auto_close_popups = false,
    };
    const child: ItemFlags = .{
        .disabled = false,
        .button_repeat = true,
        .auto_close_popups = true,
    };
    const merged: ItemFlags = mergeItemFlags(parent, child);
    try expect(merged.disabled); // parent's true wins.
    try expect(merged.button_repeat); // child contributes true.
    try expect(!merged.auto_close_popups); // parent's false wins (AND).
}

test "currentItemFlags reads top of stack" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 400,
        .canvas_h = 300,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    // Empty stack → defaults.
    try expect(!ui.currentItemFlags().button_repeat);
    try expect(ui.currentItemFlags().auto_close_popups); // default true.

    ui.pushItemFlag(.{ .button_repeat = true });
    try expect(ui.currentItemFlags().button_repeat);
    ui.popItemFlag();
    try expect(!ui.currentItemFlags().button_repeat);
}

test "button_repeat fires after initial delay then at rate" {
    // Synthesize: hold the button down, advance frame_time_seconds.
    // First press fires (regular click). Then delay (400ms) before
    // first repeat. Then steady cadence of one repeat per 50ms.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 400,
        .canvas_h = 300,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    var btn_center: Vec2 = .{ 0, 0 };
    var press_count: u32 = 0;

    // Frame 0: place button, capture rect, no click.
    {
        const ui: Ui = ctx.beginFrameRaw(
            .{ .delta_time = 0.016 },
            null,
            400,
            300,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .button_repeat = true });
        defer ui.popItemFlag();
        _ = ui.button("inc", .{});
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        btn_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }

    // Frame 1: click-down on the button. Counts as press 1
    // (regular click, the release-edge fires this). But for
    // repeat we just care that active_id_press_time gets stamped.
    {
        const snapshot: InputSnapshot = .{
            .mouse_pos = btn_center,
            .mouse_left_clicked = true,
            .mouse_left_down = true,
            .delta_time = 0.016,
        };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .button_repeat = true });
        defer ui.popItemFlag();
        if (ui.button("inc", .{})) {
            press_count += 1;
        }
    }

    // Frames 2-25: hold button, advance time. Should see no
    // repeats until cumulative held-time exceeds 400ms.
    var early_count: u32 = 0;
    var i: u32 = 0;
    while (i < 23) : (i += 1) {
        const snapshot: InputSnapshot = .{
            .mouse_pos = btn_center,
            .mouse_left_clicked = false,
            .mouse_left_down = true,
            .delta_time = 0.016, // ~23 * 16ms = ~368ms cumulative held
        };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .button_repeat = true });
        defer ui.popItemFlag();
        if (ui.button("inc", .{})) early_count += 1;
    }
    // Below 400ms delay → no repeats fired.
    try expectEqual(@as(u32, 0), early_count);

    // Frames 26+: push past 400ms then through steady repeat cadence.
    // 20 more frames at 16ms each = +320ms, total held ~688ms,
    // ~288ms past the delay → ~5-6 repeats at 50ms cadence.
    var repeat_count: u32 = 0;
    i = 0;
    while (i < 20) : (i += 1) {
        const snapshot: InputSnapshot = .{
            .mouse_pos = btn_center,
            .mouse_left_clicked = false,
            .mouse_left_down = true,
            .delta_time = 0.016,
        };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        ui.pushItemFlag(.{ .button_repeat = true });
        defer ui.popItemFlag();
        if (ui.button("inc", .{})) repeat_count += 1;
    }
    // Expect at least 3 repeats fired (conservative - exact count
    // depends on phase alignment of the 50ms cadence against
    // 16ms frame ticks). This proves the wiring works without
    // being brittle to exact timing.
    try expect(repeat_count >= 3);
}

test "beginDragDropTarget with accept_no_draw_default_rect skips highlight" {
    // Compare draw-cmd count between default vs flag-set call.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 400,
        .canvas_h = 300,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // First pass: figure out the button's rect by submitting once
    // with a far-away mouse; we just need the rect, not interaction.
    var btn_center: Vec2 = .{ 0, 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "host",
            .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 200, 200 } },
        ) orelse unreachable;
        defer h.close();
        _ = ui.button("target", .{});
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        btn_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }

    // (real test frame): ordinary call - should add the highlight ring.
    {
        const ui: Ui = ctx.beginFrameRaw(
            .{ .mouse_pos = btn_center },
            null,
            400,
            300,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("target", .{});
        // Pretend we're mid-drag: source is some OTHER widget so the
        // button can be a target.
        ctx.drag_drop.phase = .active;
        ctx.drag_drop.source_widget_id = 99999; // not our button's id
        const before: usize = ctx.current_window.?.draw_list.cmds.items.len;
        const ok: bool = ui.beginDragDropTarget(.{});
        try expect(ok);
        const after_default: usize = ctx.current_window.?.draw_list.cmds.items.len;
        try expect(after_default > before); // highlight cmd added
        ui.endDragDropTarget();
        ctx.drag_drop.phase = .idle;
        ctx.drag_drop.target_widget_id = 0;
    }
    // with accept_no_draw_default_rect - no extra cmd.
    {
        const ui: Ui = ctx.beginFrameRaw(
            .{ .mouse_pos = btn_center },
            null,
            400,
            300,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("target", .{});
        ctx.drag_drop.phase = .active;
        ctx.drag_drop.source_widget_id = 99999;
        const before: usize = ctx.current_window.?.draw_list.cmds.items.len;
        const ok: bool = ui.beginDragDropTarget(.{ .accept_no_draw_default_rect = true });
        try expect(ok);
        const after: usize = ctx.current_window.?.draw_list.cmds.items.len;
        try expectEqual(before, after); // no highlight cmd
    }
}

test "beginDragDropTarget with accept_draw_as_hovered sets last_item_hovered" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 400,
        .canvas_h = 300,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Capture button center first.
    var btn_center: Vec2 = .{ 0, 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 400, 300, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window(
            "host",
            .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 200, 200 } },
        ) orelse unreachable;
        defer h.close();
        _ = ui.button("target", .{});
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        btn_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }
    // Real test frame.
    const ui: Ui = ctx.beginFrameRaw(
        .{ .mouse_pos = btn_center },
        null,
        400,
        300,
        &gl_dummy,
        &shapes_dummy,
        &font_dummy,
    );
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    _ = ui.button("target", .{});
    ctx.drag_drop.phase = .active;
    ctx.drag_drop.source_widget_id = 99999;
    // Force last_item_hovered to false to prove the flag DOES set it
    // back to true (simulating "cursor over but the impl said no").
    ctx.current_window.?.layout.last_item_hovered = false;
    const ok: bool = ui.beginDragDropTarget(.{ .accept_draw_as_hovered = true });
    try expect(ok);
    try expect(ctx.current_window.?.layout.last_item_hovered);
}

test "endDragDropTarget: clears the target_widget_id" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.drag_drop.target_widget_id = 99;
    const u: Ui = .{ .ctx = &ctx };
    u.endDragDropTarget();
    try expectEqual(@as(Id, 0), ctx.drag_drop.target_widget_id);
}

// ============================================================================
// Tests: polish - pushStyle / popStyle / cursor helpers
// ============================================================================

test "pushStyle: stashes prior value, applies new one (Vec2 field)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const orig: Vec2 = ctx.style.frame_padding;
    u.pushStyle("frame_padding", Vec2{ 99, 88 });
    try expectEqual(@as(f32, 99), ctx.style.frame_padding[0]);
    try expectEqual(@as(f32, 88), ctx.style.frame_padding[1]);
    u.popStyle();
    try expectEqual(orig[0], ctx.style.frame_padding[0]);
    try expectEqual(orig[1], ctx.style.frame_padding[1]);
}

test "pushStyle: stashes prior value, applies new one (Color field)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const orig: Color = ctx.style.button;
    u.pushStyle("button", Color{ .r = 1, .g = 2, .b = 3, .a = 4 });
    try expectEqual(@as(u8, 1), ctx.style.button.r);
    u.popStyle();
    try expectEqual(orig.r, ctx.style.button.r);
    try expectEqual(orig.a, ctx.style.button.a);
}

test "pushStyle: stack handles nested pushes LIFO" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const orig: f32 = ctx.style.font_size;
    u.pushStyle("font_size", @as(f32, 20));
    u.pushStyle("font_size", @as(f32, 30));
    try expectEqual(@as(f32, 30), ctx.style.font_size);
    u.popStyle();
    try expectEqual(@as(f32, 20), ctx.style.font_size);
    u.popStyle();
    try expectEqual(orig, ctx.style.font_size);
}

test "popStyle: empty stack is a clean no-op" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const orig: Vec2 = ctx.style.frame_padding;
    u.popStyle(); // no matching push
    try expectEqual(orig[0], ctx.style.frame_padding[0]);
}

test "getCursorPos: returns (0,0) outside a window scope" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const p: Vec2 = u.getCursorPos();
    try expectEqual(@as(f32, 0), p[0]);
    try expectEqual(@as(f32, 0), p[1]);
}

// ----------------------------------------------------------------------------
// P6 layout/cursor query gaps
// ----------------------------------------------------------------------------
// Coverage tests for the three getters added:
//   - getCursorStartPos (screen-space, matches zimr's cursor
//                              convention; ImGui-divergent on
//                              coordinate space but consistent with
//                              the rest of zimr's cursor accessors)
//   - getWindowContentRegionMin (window-local)
//   - getWindowContentRegionMax (window-local)
// Each test pins the outside-window fallback (returns.{0,0}) AND the
// inside-window math against a hand-set LayoutScope.

test "P6: getCursorStartPos returns (0,0) outside a window scope" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const p: Vec2 = u.getCursorStartPos();
    try expectEqual(@as(f32, 0), p[0]);
    try expectEqual(@as(f32, 0), p[1]);
}

test "P6: getCursorStartPos returns layout.origin (screen-space)" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{
        .id = 1,
        .pos = .{ 100, 200 },
        .size = .{ 320, 240 },
        .layout = .{
            .origin = .{ 108, 230 },
            .cursor_pos = .{ 108, 250 },
            .work_rect_max = .{ 416, 432 },
        },
    };
    defer w.draw_list.deinit(std.testing.allocator);
    ctx.current_window = &w;
    const u: Ui = .{ .ctx = &ctx };
    // Screen-space — equals layout.origin verbatim.
    try expectEqual(@as(Vec2, .{ 108, 230 }), u.getCursorStartPos());
}

test "P6: getWindowContentRegionMin returns (0,0) outside a window scope" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(@as(Vec2, .{ 0, 0 }), u.getWindowContentRegionMin());
}

test "P6: getWindowContentRegionMax returns (0,0) outside a window scope" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(@as(Vec2, .{ 0, 0 }), u.getWindowContentRegionMax());
}

test "P6: getWindowContentRegionMin/Max return window-local coords" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{
        .id = 1,
        .pos = .{ 100, 200 },
        .size = .{ 320, 240 },
        .layout = .{
            // Origin sits 8 px inside the window's top-left after
            // chrome + padding — a typical decorated-window layout.
            .origin = .{ 108, 230 },
            // Work rect's bottom-right is inset by similar padding
            // from the window's bottom-right (100+320=420, 200+240=440).
            .work_rect_max = .{ 412, 432 },
        },
    };
    defer w.draw_list.deinit(std.testing.allocator);
    ctx.current_window = &w;
    const u: Ui = .{ .ctx = &ctx };
    // Window-local = scope coords minus window pos.
    try expectEqual(@as(Vec2, .{ 8, 30 }), u.getWindowContentRegionMin());
    try expectEqual(@as(Vec2, .{ 312, 232 }), u.getWindowContentRegionMax());
    // Content region size = max - min, useful for callers.
    const min_corner: Vec2 = u.getWindowContentRegionMin();
    const max_corner: Vec2 = u.getWindowContentRegionMax();
    try expectEqual(@as(f32, 304), max_corner[0] - min_corner[0]);
    try expectEqual(@as(f32, 202), max_corner[1] - min_corner[1]);
}

test "setCursorPos: updates the active window's cursor + clears line_height" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w = Window{
        .id = 1,
        .pos = .{ 0, 0 },
        .size = .{ 200, 200 },
        .layout = .{
            .cursor_pos = .{ 50, 50 },
            .line_height = 10,
        },
        .draw_list = .{},
    };
    defer w.draw_list.deinit(ctx.frame_arena.allocator());
    ctx.current_window = &w;
    const u: Ui = .{ .ctx = &ctx };
    u.setCursorPos(.{ 100, 75 });
    try expectEqual(@as(f32, 100), w.layout.cursor_pos[0]);
    try expectEqual(@as(f32, 75), w.layout.cursor_pos[1]);
    try expectEqual(@as(f32, 0), w.layout.line_height);
}

// ============================================================================
// Tests: Turn 188 - imgui feature gap fills
// ============================================================================

test "InputTextOpts.hint: defaults to empty string" {
    const o: InputTextOpts = .{};
    try expectEqual(@as(usize, 0), o.hint.len);
}

test "SliderAngleOpts: degrees defaults of -360/360, default format" {
    const o: SliderAngleOpts = .{};
    try expectEqual(@as(f32, -360), o.min_deg);
    try expectEqual(@as(f32, 360), o.max_deg);
    try expect(std.mem.indexOf(u8, o.fmt, "°") != null);
}

test "setKeyboardFocusHere: sets next_widget_take_focus flag" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(false, ctx.next_widget_take_focus);
    u.setKeyboardFocusHere();
    try expectEqual(true, ctx.next_widget_take_focus);
}

test "beginItemTooltip: returns false when no item is hovered" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // No active window → isItemHovered returns false → beginItemTooltip
    // short-circuits before touching the tooltip window state.
    const u: Ui = .{ .ctx = &ctx };
    try expectEqual(false, u.beginItemTooltip());
    try expectEqual(false, ctx.tooltip_active_this_frame);
}

test "calcTextSize: returns the same size measureTextS would" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    const a: Vec2 = u.calcTextSize("Hello, world!");
    const b: Vec2 = measureTextS(&ctx, "Hello, world!");
    try expectEqual(a[0], b[0]);
    try expectEqual(a[1], b[1]);
}

test "comboImpl: opening one combo closes any other (single-slot)" {
    // The combo_open_id is a single Id - not a set. Setting it to a
    // new value implicitly closes whichever was open.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.combo_open_id = 100; // "combo A" open
    ctx.combo_open_id = 200; // user clicks "combo B" - A is now closed
    try expectEqual(@as(Id, 200), ctx.combo_open_id);
    try expect(ctx.combo_open_id != 100);
}

// ============================================================================
// Widget: treeNode + treePop (recursive hierarchy)
// ============================================================================
// Difference from `collapsingHeader`: open/closed state is library-
// managed (no `*bool` parameter), kept in `UiContext.tree_open_state`
// keyed by hashed ID so it persists across frames. Open nodes bump
// `Window.layout.indent_x` so children visually nest; `treePop` undoes the
// bump.

// full TreeNode variant. Honors the wired-MVP
// flags: leaf, default_open, framed, selected, bullet, frame_padding,
// open_trigger (click_anywhere + click_arrow), span (default +
// avail_width). Other fields are accepted but inert (with TODO
// references in TreeNodeOpts). Aux APIs (setNextItemOpen,
// isItemToggledOpen, treeNodeGetOpen, getTreeNodeToLabelSpacing) wired.
// Imgui reference: TreeNodeBehavior at imgui_widgets.cpp:6890+.

test "treeNode open state map: missing entry defaults to closed" {
    // Pure data-structure check - we can't drive the full widget
    // without a UiContext + GL stub, but the lookup pattern is the
    // bit most likely to drift if someone refactors `tree_open_state`.
    var map = std.AutoHashMapUnmanaged(Id, bool){};
    defer map.deinit(std.testing.allocator);

    try expectEqual(@as(?bool, null), map.get(0xDEADBEEF));

    try map.put(std.testing.allocator, 0xCAFE, true);
    try expectEqual(@as(?bool, true), map.get(0xCAFE));

    try map.put(std.testing.allocator, 0xCAFE, false);
    try expectEqual(@as(?bool, false), map.get(0xCAFE));
}

test "indent_amount is the ImGui default" {
    // Lock the value - accidentally drifting it from 21 would make
    // existing screenshots not match ImGui.
    try expectEqual(@as(f32, 21), indent_amount);
}

test "applyAccent: button alpha follows ImGui dark default ratio (40%)" {
    var s: Style = Style.dark_default;
    const accent = Color{ .r = 200, .g = 100, .b = 50, .a = 255 };
    s.applyAccent(accent);
    // Button is accent at 40% alpha - preserves RGB, alpha = round(255*0.4) = 102.
    try expectEqual(@as(u8, 200), s.button.r);
    try expectEqual(@as(u8, 100), s.button.g);
    try expectEqual(@as(u8, 50), s.button.b);
    try expectEqual(@as(u8, 102), s.button.a);
}

test "applyAccent: button_hovered is accent at full alpha" {
    var s: Style = Style.dark_default;
    const accent = Color{ .r = 14, .g = 165, .b = 233, .a = 255 }; // sky_500
    s.applyAccent(accent);
    try expectEqual(accent, s.button_hovered);
}

test "applyAccent: button_active is darker than button_hovered" {
    var s: Style = Style.dark_default;
    s.applyAccent(Color{ .r = 200, .g = 200, .b = 200, .a = 255 });
    // brightness(0.8) → each channel × 0.8 = 160.
    try expectEqual(@as(u8, 160), s.button_active.r);
    try expect(s.button_active.r < s.button_hovered.r);
}

test "applyAccent: structural chrome (text, border, window_bg) untouched" {
    var s: Style = Style.dark_default;
    const before_text: Color = s.text;
    const before_border: Color = s.border;
    const before_winbg: Color = s.window_bg;
    const before_titlebg: Color = s.title_bg;
    s.applyAccent(Color{ .r = 244, .g = 63, .b = 94, .a = 255 }); // rose_500
    try expectEqual(before_text, s.text);
    try expectEqual(before_border, s.border);
    try expectEqual(before_winbg, s.window_bg);
    try expectEqual(before_titlebg, s.title_bg);
}

test "applyTailwind: each hue maps to the matching _500 in the palette" {
    var s: Style = Style.dark_default;
    s.applyTailwind(.amber);
    try expectEqual(types.amber_500.r, s.button_hovered.r);
    try expectEqual(types.amber_500.g, s.button_hovered.g);
    try expectEqual(types.amber_500.b, s.button_hovered.b);

    s.applyTailwind(.violet);
    try expectEqual(types.violet_500.r, s.button_hovered.r);
    try expectEqual(types.violet_500.g, s.button_hovered.g);
    try expectEqual(types.violet_500.b, s.button_hovered.b);
}

test "cursorIndexFromX: empty string returns 0" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expectEqual(@as(usize, 0), cursorIndexFromX(&ctx, "", 50));
    try expectEqual(@as(usize, 0), cursorIndexFromX(&ctx, "", 0));
}

test "cursorIndexFromX: zero or negative x returns 0" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expectEqual(@as(usize, 0), cursorIndexFromX(&ctx, "hello", 0));
    try expectEqual(@as(usize, 0), cursorIndexFromX(&ctx, "hello", -5));
}

test "cursorIndexFromX: very large x returns end-of-string" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    try expectEqual(@as(usize, 5), cursorIndexFromX(&ctx, "hello", 10000));
}

test "cursorIndexFromX: monotonic - later x ⇒ greater-or-equal index" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var prev: usize = 0;
    var x: f32 = 0;
    while (x <= 200) : (x += 5) {
        const got = cursorIndexFromX(&ctx, "hello world", x);
        try expect(got >= prev);
        prev = got;
    }
}

// ============================================================================
// Geometry helpers.
// ============================================================================

test "pointInRect: classic in/out" {
    const r = Rectangle{ .x = 10, .y = 10, .width = 100, .height = 50 };
    try expect(pointInRect(.{ 50, 30 }, r));
    try expect(!pointInRect(.{ 5, 30 }, r));
    try expect(!pointInRect(.{ 50, 200 }, r));
    // Edge inclusivity matches GUI convention: left/top inclusive,
    // right/bottom exclusive.
    try expect(pointInRect(.{ 10, 10 }, r));
    try expect(!pointInRect(.{ 110, 60 }, r));
}

// ============================================================================
// tests - markItemPost transitions, setNextWindow*,
// item-state queries. These pin the architectural contracts the
// per-widget bookkeeping relies on (and that user code reads via
// `isItemActive` / `isItemEdited` / `isItemDeactivatedAfterEdit`).
// ============================================================================

test " markItemPost - id=0 items have all flags false" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    // Simulate a non-interactive widget: id=0 plus a hovered rect.
    w.layout.last_item_id = 0;
    w.layout.last_item_hovered = true;
    ctx.input.mouse_left_clicked = true;
    markItemPost(&ctx, &w, true); // even with edited=true, id=0 forces all-false
    try expect(!w.layout.last_item_edited);
    try expect(!w.layout.last_item_clicked);
    try expect(!w.layout.last_item_activated);
    try expect(!w.layout.last_item_deactivated);
    try expect(!w.layout.last_item_deactivated_after_edit);
}

test " markItemPost - clicked = hovered AND mouse-down-this-frame" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    w.layout.last_item_id = 0xABCD;
    w.layout.last_item_hovered = true;
    ctx.input.mouse_left_clicked = true;
    markItemPost(&ctx, &w, false);
    try expect(w.layout.last_item_clicked);

    // No-click frame: clicked should clear.
    ctx.input.mouse_left_clicked = false;
    markItemPost(&ctx, &w, false);
    try expect(!w.layout.last_item_clicked);

    // Click-but-not-hovered: clicked stays false.
    w.layout.last_item_hovered = false;
    ctx.input.mouse_left_clicked = true;
    markItemPost(&ctx, &w, false);
    try expect(!w.layout.last_item_clicked);
}

test " markItemPost - activation transitions over 3 frames" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    const id: Id = 0x1234;

    // Frame 1: widget inactive → no transition.
    w.layout.last_item_id = id;
    ctx.active_id = 0;
    ctx.active_id_prev_frame = 0;
    markItemPost(&ctx, &w, false);
    try expect(!w.layout.last_item_activated);
    try expect(!w.layout.last_item_deactivated);

    // Frame 2: widget activates this frame.
    ctx.active_id = id;
    ctx.active_id_prev_frame = 0;
    markItemPost(&ctx, &w, false);
    try expect(w.layout.last_item_activated);
    try expect(!w.layout.last_item_deactivated);

    // Frame 3: widget continues active.
    ctx.active_id = id;
    ctx.active_id_prev_frame = id;
    markItemPost(&ctx, &w, false);
    try expect(!w.layout.last_item_activated);
    try expect(!w.layout.last_item_deactivated);

    // Frame 4: widget deactivates.
    ctx.active_id = 0;
    ctx.active_id_prev_frame = id;
    markItemPost(&ctx, &w, false);
    try expect(!w.layout.last_item_activated);
    try expect(w.layout.last_item_deactivated);
}

test " markItemPost - deactivatedAfterEdit sticky during activation" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    const id: Id = 0x5678;
    w.layout.last_item_id = id;

    // Activate without edit.
    ctx.active_id = id;
    ctx.active_id_prev_frame = 0;
    markItemPost(&ctx, &w, false);
    try expect(!ctx.active_id_was_edited);

    // Mid-activation edit fires - flag goes sticky.
    ctx.active_id = id;
    ctx.active_id_prev_frame = id;
    markItemPost(&ctx, &w, true);
    try expect(ctx.active_id_was_edited);

    // Continue holding (no edit this frame) - flag remains sticky.
    markItemPost(&ctx, &w, false);
    try expect(ctx.active_id_was_edited);

    // Deactivate - surface deactivatedAfterEdit, then clear sticky.
    ctx.active_id = 0;
    ctx.active_id_prev_frame = id;
    markItemPost(&ctx, &w, false);
    try expect(w.layout.last_item_deactivated);
    try expect(w.layout.last_item_deactivated_after_edit);
    try expect(!ctx.active_id_was_edited); // sticky cleared
}

test " markItemPost - no-edit deactivation does NOT set deactivatedAfterEdit" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    const id: Id = 0x9ABC;
    w.layout.last_item_id = id;

    // Activate, hold, release - never edited.
    ctx.active_id = id;
    ctx.active_id_prev_frame = 0;
    markItemPost(&ctx, &w, false);
    ctx.active_id_prev_frame = id;
    markItemPost(&ctx, &w, false);
    ctx.active_id = 0;
    markItemPost(&ctx, &w, false);
    try expect(w.layout.last_item_deactivated);
    try expect(!w.layout.last_item_deactivated_after_edit);
}

test " setNextWindow* state persists in UiContext + clears on use" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Field defaults - no overrides pending.
    try expect(ctx.next_window_pos == null);
    try expect(ctx.next_window_size == null);
    try expect(ctx.next_window_pos_always); // default is "every frame"
    try expect(ctx.next_window_size_always);
    try expect(!ctx.next_window_focus);

    // Setting via the public API stores the override.
    const ui: Ui = .{ .ctx = &ctx };
    ui.setNextWindowPos(.{ 100, 50 }, .{});
    ui.setNextWindowSize(.{ 300, 200 }, .{ .once = true });
    ui.setNextWindowFocus();
    try expectEqual(@as(?Vec2, .{ 100, 50 }), ctx.next_window_pos);
    try expectEqual(@as(?Vec2, .{ 300, 200 }), ctx.next_window_size);
    try expect(ctx.next_window_pos_always); // .{}.once defaults to false → always = !once = true
    try expect(!ctx.next_window_size_always); // .once = true → always = false
    try expect(ctx.next_window_focus);
}

test " getItemRect helpers compute Min/Max/Size from last_item_rect" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 } };
    defer w.draw_list.deinit(std.testing.allocator);
    w.layout.last_item_rect = .{ .x = 20, .y = 30, .width = 50, .height = 40 };
    ctx.current_window = &w;
    const ui: Ui = .{ .ctx = &ctx };
    try expectEqual(@as(Vec2, .{ 20, 30 }), ui.getItemRectMin());
    try expectEqual(@as(Vec2, .{ 70, 70 }), ui.getItemRectMax());
    try expectEqual(@as(Vec2, .{ 50, 40 }), ui.getItemRectSize());
}

test " getWindow* helpers return current window pos/size" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    var w: Window = .{ .id = 1, .pos = .{ 100, 200 }, .size = .{ 320, 240 } };
    defer w.draw_list.deinit(std.testing.allocator);
    ctx.current_window = &w;
    const ui: Ui = .{ .ctx = &ctx };
    try expectEqual(@as(Vec2, .{ 100, 200 }), ui.getWindowPos());
    try expectEqual(@as(Vec2, .{ 320, 240 }), ui.getWindowSize());
    try expectEqual(@as(f32, 320), ui.getWindowWidth());
    try expectEqual(@as(f32, 240), ui.getWindowHeight());
}

test " isWindow* defaults safely outside a window scope" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };
    // No current_window → all queries return false / zero.
    try expect(!ui.isWindowFocused(.{}));
    try expect(!ui.isWindowHovered(.{}));
    try expectEqual(@as(Vec2, .{ 0, 0 }), ui.getWindowPos());
    try expectEqual(@as(f32, 0), ui.getWindowWidth());
}

// ============================================================================
// - vertical scrollbar + auto-fit gating
// These pin the scroll math contract - when content overflows the
// window viewport, scroll_max_y is positive; clamping keeps scroll_y
// in `[0, scroll_max_y]`; mouse-wheel translates wheel ticks; the
// auto-fit grow path defers to user_resized.
// ============================================================================

test " openWindow shifts cursor by scroll_y" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window("scroll_test", .{}) orelse unreachable;
    const w: *Window = handle.win;
    // First frame: scroll_y is zero; cursor sits at inner_origin.
    const inner_origin_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    try expectEqual(inner_origin_y, w.layout.cursor_pos[1]);
    handle.close();

    // Second frame: simulate scroll, re-open, cursor should be shifted up.
    w.scroll_y = 30;
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle2: WindowHandle = ui.window("scroll_test", .{}) orelse unreachable;
    const w2: *Window = handle2.win;
    try expectEqual(inner_origin_y - 30, w2.layout.cursor_pos[1]);
    handle2.close();
}

test " closeWindow computes scroll_max_y from content overflow" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window(
        "overflow_test",
        .{ .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    const w: *Window = handle.win;
    // Force `user_resized` so closeWindow doesn't grow the window
    // to absorb our simulated content.
    w.user_resized = true;
    // Simulate 300px of content (cursor ends 300 below origin).
    const inner_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    w.layout.cursor_max = .{ w.pos[0] + 100, inner_y + 300 };
    handle.close();

    const viewport_h: f32 = 100 - ctx.style.title_bar_height - ctx.style.window_padding[1];
    try expectEqual(@as(f32, 300 - viewport_h), w.scroll_max_y);
}

test " scroll_y clamps to [0, scroll_max_y]" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    // First frame: create the window so subsequent frames can mutate
    // its persistent state directly.
    const create: WindowHandle = ui.window(
        "clamp_test",
        .{ .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    const w: *Window = create.win;
    w.user_resized = true;
    create.close();

    // Second frame: pre-set scroll_y way past content extent. The
    // window's openWindow will pick it up and shift cursor_pos
    // accordingly; we record cursor_max consistent with that shift.
    w.scroll_y = 99999;
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window(
        "clamp_test",
        .{ .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    // Simulate a widget that records 200px of content past the
    // (shifted-by-scroll_y) cursor position.
    h.win.layout.cursor_max = .{ h.win.layout.cursor_pos[0], h.win.layout.cursor_pos[1] + 200 };
    h.close();
    try expect(h.win.scroll_y == h.win.scroll_max_y);
    try expect(h.win.scroll_y < 99999);

    // Third frame: negative scroll_y also clamps to 0.
    h.win.scroll_y = -50;
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h2: WindowHandle = ui.window(
        "clamp_test",
        .{ .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    h2.win.layout.cursor_max = .{ h2.win.layout.cursor_pos[0], h2.win.layout.cursor_pos[1] + 200 };
    h2.close();
    try expectEqual(@as(f32, 0), h2.win.scroll_y);
}

test " mouse wheel translates scroll_y on the hovered window" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wheel_input: InputSnapshot = .{ .mouse_wheel_y = -1, .mouse_pos = .{ 100, 100 } };
    const ui: Ui = ctx.beginFrameRaw(wheel_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window(
        "wheel_test",
        .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    const w: *Window = handle.win;
    w.user_resized = true;
    // Mouse pos (100, 100) is inside window rect (50, 50, 200, 100) →
    // openWindow set hovered_window_id to this window.
    try expect(ctx.hovered_window_id == w.id);
    // Simulate enough content to enable scrolling.
    const inner_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    w.layout.cursor_max = .{ w.pos[0], inner_y + 500 };
    handle.close();
    // Wheel = -1 ⇒ scroll DOWN ⇒ scroll_y increases by `5 * font_size`.
    try expect(w.scroll_y > 0);
    try expect(w.scroll_y <= w.scroll_max_y);
}

test "touch-pan: drag on window body scrolls when content overflows" {
    // Mobile devices don't emit wheel events from finger drags; touch
    // is mirrored to left-mouse-down + mouse_move.  closeWindow's
    // touch-pan branch translates this into scroll_y / scroll_x.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Frame 1 — touch begins inside the window body.
    const touch_start: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_left_down = true,
        .mouse_left_clicked = true,
    };
    const ui1: Ui = ctx.beginFrameRaw(touch_start, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h1: WindowHandle = ui1.window(
        "pan_test",
        .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    const w: *Window = h1.win;
    w.user_resized = true;
    // Inject content overflow so scroll_max_y > 0.
    const inner_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    w.layout.cursor_max = .{ w.pos[0], inner_y + 500 };
    h1.close();
    try expect(w.touch_pan_active);
    const start_scroll: f32 = w.scroll_y;
    // Frame 2 — finger moved up 40 px while still held.  Content should
    // scroll DOWN (scroll_y increases) by 40.
    const touch_drag: InputSnapshot = .{
        .mouse_pos = .{ 100, 60 },
        .mouse_left_down = true,
    };
    const ui2: Ui = ctx.beginFrameRaw(touch_drag, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h2: WindowHandle = ui2.window("pan_test", .{}) orelse unreachable;
    h2.win.layout.cursor_max = .{ h2.win.pos[0], inner_y + 500 };
    h2.close();
    try expectApproxEqAbs(start_scroll + 40, h2.win.scroll_y, 0.5);
    // Frame 3 — finger released.  Pan deactivates.
    const touch_release: InputSnapshot = .{
        .mouse_pos = .{ 100, 60 },
        .mouse_left_down = false,
    };
    const ui3: Ui = ctx.beginFrameRaw(touch_release, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h3: WindowHandle = ui3.window("pan_test", .{}) orelse unreachable;
    h3.close();
    try expect(!h3.win.touch_pan_active);
}

test "touch-pan: does not hijack when the drag starts outside the window" {
    // A finger that presses DOWN outside the window and then drags across
    // it must NOT start scrolling that window. (On touch, hover follows the
    // finger, so the old code started a pan the moment the finger entered.)
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Frame 1 — press down well outside the window's rect.
    const down_outside: InputSnapshot = .{
        .mouse_pos = .{ 500, 450 },
        .mouse_left_down = true,
        .mouse_left_clicked = true,
    };
    const ui1: Ui = ctx.beginFrameRaw(down_outside, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h1: WindowHandle = ui1.window(
        "pan_outside",
        .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 200, 100 } },
    ) orelse unreachable;
    const w: *Window = h1.win;
    w.user_resized = true;
    const inner_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    w.layout.cursor_max = .{ w.pos[0], inner_y + 500 };
    h1.close();
    // Simulate the sticky-hover hazard: hovered_window_id never clears, so it
    // can still point at this window while the finger is in empty space.
    ctx.hovered_window_id = w.id;
    try expect(!w.touch_pan_active);
    // Frame 2 — finger still held, now dragged OVER the window body. The
    // press did not begin here, so no pan must start.
    const drag_in: InputSnapshot = .{
        .mouse_pos = .{ 100, 90 },
        .mouse_left_down = true,
    };
    const ui2: Ui = ctx.beginFrameRaw(drag_in, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h2: WindowHandle = ui2.window("pan_outside", .{}) orelse unreachable;
    h2.win.layout.cursor_max = .{ h2.win.pos[0], inner_y + 500 };
    h2.close();
    try expect(!h2.win.touch_pan_active);
    try expectEqual(@as(f32, 0), h2.win.scroll_y);
    // Frame 3 — a brand-new press DOWN in empty space while hovered_window_id
    // still (stale) points at the window. Geometric gate must reject it.
    ctx.hovered_window_id = w.id;
    const press_empty: InputSnapshot = .{
        .mouse_pos = .{ 600, 500 },
        .mouse_left_down = true,
        .mouse_left_clicked = true,
    };
    const ui3: Ui = ctx.beginFrameRaw(press_empty, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h3: WindowHandle = ui3.window("pan_outside", .{}) orelse unreachable;
    h3.win.layout.cursor_max = .{ h3.win.pos[0], inner_y + 500 };
    h3.close();
    try expect(!h3.win.touch_pan_active);
    try expectEqual(@as(f32, 0), h3.win.scroll_y);
}

test "touch-pan: does not activate when there's nothing to scroll" {
    // Pan must remain dormant on windows whose content fits — otherwise
    // every touch in a non-scrolling window would briefly start a
    // gesture and potentially clobber widget interactions.
    // Pan must remain dormant on windows whose content fits — otherwise
    // every touch in a non-scrolling window would briefly start a
    // gesture and potentially clobber widget interactions.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const touch: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_left_down = true,
    };
    const ui: Ui = ctx.beginFrameRaw(touch, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window(
        "no_overflow",
        .{ .initial_pos = .{ 50, 50 }, .initial_size = .{ 200, 400 } },
    ) orelse unreachable;
    h.win.user_resized = true;
    // Content fits well within 400 px height — no overflow.
    h.close();
    try expect(!h.win.touch_pan_active);
    try expectEqual(@as(f32, 0), h.win.scroll_max_y);
}

test " auto-fit grows window only while user_resized = false" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window(
        "autofit_test",
        .{ .initial_size = .{ 100, 50 } },
    ) orelse unreachable;
    const w: *Window = handle.win;
    // No setNextWindowSize, no manual resize → user_resized stays false.
    try expect(!w.user_resized);
    // Pretend a widget grew the cursor past the initial size.
    const inner_y: f32 = w.pos[1] + ctx.style.title_bar_height + ctx.style.window_padding[1];
    w.layout.cursor_max = .{ w.pos[0] + 250, inner_y + 200 };
    handle.close();
    try expect(w.size[0] >= 250);
    try expect(w.size[1] > 200);

    // Now mark resized and re-submit with the same overflow - size
    // should NOT grow; scroll_max_y should be positive instead.
    const prior_w: f32 = w.size[0];
    const prior_h: f32 = w.size[1];
    w.user_resized = true;
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h2: WindowHandle = ui.window(
        "autofit_test",
        .{ .initial_size = .{ 100, 50 } },
    ) orelse unreachable;
    h2.win.layout.cursor_max = .{ h2.win.pos[0], inner_y + prior_h + 300 };
    h2.close();
    try expectEqual(prior_w, h2.win.size[0]);
    try expectEqual(prior_h, h2.win.size[1]);
    try expect(h2.win.scroll_max_y > 0);
}

// ============================================================================
// tests - layout helpers, item-width stack, disabled gating,
// groups. Each pins one architectural contract.
// ============================================================================

test " multiplyAlpha clamps + scales" {
    // Opaque red, mul 0.5 → 255*0.5 = 127.5, round-to-nearest → 128.
    const red: ColorU32 = 0xFF0000FF; // packed: AABBGGRR
    const half: ColorU32 = multiplyAlpha(red, 0.5);
    try expectEqual(@as(u32, 0x800000FF), half);
    // mul 1.0 short-circuits - same value back.
    try expectEqual(red, multiplyAlpha(red, 1.0));
    // mul 0 → fully transparent.
    try expectEqual(@as(u32, 0x000000FF), multiplyAlpha(red, 0));
}

test " effectiveItemWidth priority - next > stack > opts > default" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    // Default falls through when nothing set.
    try expectEqual(@as(f32, 100), effectiveItemWidth(&ctx, 0, 100));
    // opts wins over default.
    try expectEqual(@as(f32, 50), effectiveItemWidth(&ctx, 50, 100));
    // Stack wins over opts.
    ctx.item_width_stack.append(75) catch unreachable;
    try expectEqual(@as(f32, 75), effectiveItemWidth(&ctx, 50, 100));
    // next_item_width wins over stack - and gets cleared on read.
    ctx.next_item_width = 200;
    try expectEqual(@as(f32, 200), effectiveItemWidth(&ctx, 50, 100));
    try expect(ctx.next_item_width == null);
    // After clear, fall back to stack.
    try expectEqual(@as(f32, 75), effectiveItemWidth(&ctx, 50, 100));
}

test " pushItemWidth/popItemWidth balance" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };
    ui.pushItemWidth(40);
    ui.pushItemWidth(60);
    try expectEqual(@as(?f32, 60), ctx.item_width_stack.top());
    ui.popItemWidth();
    try expectEqual(@as(?f32, 40), ctx.item_width_stack.top());
    ui.popItemWidth();
    try expectEqual(@as(?f32, null), ctx.item_width_stack.top());
}

test " beginDisabled gates input on outermost transition" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };

    // Pretend a click is pending.
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;

    ui.beginDisabled(true);
    // Input cleared while disabled.
    try expect(!ctx.input.mouse_left_clicked);
    try expect(!ctx.input.mouse_left_down);
    try expectEqual(ctx.style.disabled_alpha, ctx.alpha_mul);

    // Nested beginDisabled bumps count but doesn't re-save.
    ui.beginDisabled(true);
    try expectEqual(@as(u32, 2), ctx.disabled_count);
    ui.endDisabled(); // count back to 1, still disabled
    try expect(!ctx.input.mouse_left_clicked);

    // Outermost endDisabled restores.
    ui.endDisabled();
    try expect(ctx.input.mouse_left_clicked);
    try expect(ctx.input.mouse_left_down);
    try expectEqual(@as(f32, 1.0), ctx.alpha_mul);
}

test " beginDisabled(false) is a no-op pass-through" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };
    ctx.input.mouse_left_clicked = true;
    ui.beginDisabled(false);
    try expect(ctx.input.mouse_left_clicked); // not gated
    try expectEqual(@as(u32, 0), ctx.disabled_count);
    ui.endDisabled();
    try expectEqual(@as(usize, 0), ctx.disabled_marker_stack.len);
}

test " nested disabled - false-inside-true stays disabled" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };
    ui.beginDisabled(true);
    ui.beginDisabled(false); // can't undo a parent-disabled
    try expect(ui.isDisabled());
    ui.endDisabled();
    try expect(ui.isDisabled());
    ui.endDisabled();
    try expect(!ui.isDisabled());
}

test " getContentRegionAvail returns zero outside a window scope" {
    var ctx: UiContext = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui: Ui = .{ .ctx = &ctx };
    try expectEqual(@as(Vec2, .{ 0, 0 }), ui.getContentRegionAvail());
}

test " dummy advances cursor + records last_item_rect" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window("dummy_test", .{}) orelse unreachable;
    const w: *Window = handle.win;
    const before: Vec2 = w.layout.cursor_pos;
    ui.dummy(.{ 50, 30 });
    // Last item rect should match the dummy size.
    try expectEqual(@as(f32, 50), w.layout.last_item_rect.width);
    try expectEqual(@as(f32, 30), w.layout.last_item_rect.height);
    // Cursor should have advanced down by 30 + item_spacing.y.
    const after: Vec2 = w.layout.cursor_pos;
    try expect(after[1] > before[1]);
    handle.close();
}

test " beginGroup/endGroup record group bounding rect" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window("group_test", .{}) orelse unreachable;
    const w: *Window = handle.win;
    ui.beginGroup();
    const at_start: Vec2 = w.layout.cursor_pos;
    // Simulate two widgets via dummy - group's rect should span them.
    ui.dummy(.{ 80, 20 });
    ui.dummy(.{ 80, 20 });
    ui.endGroup();
    // After endGroup, last_item_rect is the group bounding box.
    try expect(w.layout.last_item_rect.x == at_start[0]);
    try expect(w.layout.last_item_rect.width >= 80);
    try expect(w.layout.last_item_rect.height >= 40);
    handle.close();
}

// ============================================================================
// tests - popup primitives. Each pins one architectural
// contract: open/close lifecycle, isPopupOpen accuracy, beginPopup
// returns false when closed, click-outside dismissal, frame-grace
// for the opening click.
// ============================================================================

test " openPopup + isPopupOpen + closeCurrentPopup lifecycle" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer handle.close();

    // Initially closed.
    try expect(!ui.isPopupOpen("ctx_menu"));
    ui.openPopup("ctx_menu");
    try expect(ui.isPopupOpen("ctx_menu"));

    // Open + begin scope returns true.
    try expect(ui.beginPopup("ctx_menu"));
    ui.closeCurrentPopup();
    ui.endPopup();
    try expect(!ui.isPopupOpen("ctx_menu"));
}

test " beginPopup returns false when popup is closed" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const handle: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer handle.close();
    // No openPopup call → beginPopup returns false; no end needed.
    try expect(!ui.beginPopup("never_opened"));
}

test " popup ID is hashed against parent window - no collisions" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    // Open popup "menu" inside window A.
    const a: WindowHandle = ui.window("A", .{}) orelse unreachable;
    ui.openPopup("menu");
    a.close();
    // Same str_id "menu" inside window B - should be a DIFFERENT
    // popup (different parent window id → different hash).
    const b: WindowHandle = ui.window("B", .{}) orelse unreachable;
    try expect(!ui.isPopupOpen("menu")); // B's "menu" is closed
    b.close();
    // A's "menu" still open.
    const a2: WindowHandle = ui.window("A", .{}) orelse unreachable;
    try expect(ui.isPopupOpen("menu"));
    a2.close();
}

test " popup persists across frames until closed" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        ui.openPopup("p");
        h.close();
    }
    // Next frame - popup still open.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        try expect(ui.isPopupOpen("p"));
        h.close();
    }
}

test " click-outside dismisses popup on subsequent frames" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Frame 1 - open at anchor (200, 200). Popup rect roughly (200, 200, 120, 60).
    {
        const opening_input: InputSnapshot = .{ .mouse_pos = .{ 200, 200 } };
        const ui: Ui = ctx.beginFrameRaw(opening_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        ui.openPopup("p");
        // Submit the popup so its size is recorded.
        if (ui.beginPopup("p")) {
            ui.endPopup();
        }
        h.close();
    }
    // Frame 2 - click far outside popup rect. Frame-grace allows
    // dismissal because opened_at_frame < frame_count.
    {
        const click_outside: InputSnapshot = .{
            .mouse_pos = .{ 500, 500 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_outside, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        try expect(!ui.isPopupOpen("p"));
        h.close();
    }
}

test " opening-frame click does NOT dismiss the popup" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Single frame - click happens THIS frame; openPopup happens
    // THIS frame at the same time. The frame-grace check should
    // suppress the dismiss.
    const open_and_click: InputSnapshot = .{
        .mouse_pos = .{ 200, 200 },
        .mouse_left_clicked = true,
    };
    const ui: Ui = ctx.beginFrameRaw(open_and_click, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    ui.openPopup("p");
    // Same frame - popup should remain open inside the host scope.
    // (The frame-grace blocked the dismiss check at beginFrame, AND
    // openPopup happened post-beginFrame so the dismissal pass had
    // already run.)
    try expect(ui.isPopupOpen("p"));
    h.close();
}

// ============================================================================
// tests - modal popups, beginPopupContextItem, main menu
// bar + menus + menu items.
// ============================================================================

test " beginPopupContextItem auto-opens on right-click on hovered item" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const input: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_right_clicked = true,
    };
    const ui: Ui = ctx.beginFrameRaw(input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    // Forge a "last item" rect that contains the mouse pos.
    h.win.layout.last_item_id = 42;
    h.win.layout.last_item_rect = .{ .x = 50, .y = 50, .width = 200, .height = 100 };
    h.win.layout.last_item_hovered = true;
    // Right-click + last_item_hovered → beginPopupContextItem opens AND begins.
    const opened: bool = ui.beginPopupContextItem("ctx");
    try expect(opened);
    if (opened) {
        ui.endPopup();
    }
    // Popup stays open across the right-click frame - confirm.
    try expect(ui.isPopupOpen("ctx"));
    h.close();
}

test " beginPopupContextItem returns false when not hovered" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const input: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_right_clicked = true,
    };
    const ui: Ui = ctx.beginFrameRaw(input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    h.win.layout.last_item_id = 42;
    h.win.layout.last_item_hovered = false; // NOT hovered
    try expect(!ui.beginPopupContextItem("ctx"));
    h.close();
}

test " beginPopupModal marks PopupState as modal" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    var open: bool = true;
    ui.openPopup("about");
    const opened: bool = ui.beginPopupModal("about", &open);
    try expect(opened);
    // After upgrade, PopupState.is_modal should be true.
    const parent_id: Id = h.win.id;
    const id: Id = hashStr(parent_id, "about");
    const state: PopupState = ctx.popup_open.get(id) orelse unreachable;
    try expect(state.is_modal);
    if (opened) {
        ui.endPopup();
    }
    h.close();
}

test " beginPopupModal with *open=false dismisses + returns false" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    ui.openPopup("about");
    var open: bool = false;
    try expect(!ui.beginPopupModal("about", &open));
    try expect(!ui.isPopupOpen("about"));
    h.close();
}

test " modal popups ignore click-outside dismissal" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Frame 1 - open + mark modal.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        ui.openPopup("modal_test");
        var open: bool = true;
        if (ui.beginPopupModal("modal_test", &open)) {
            ui.endPopup();
        }
        h.close();
    }
    // Frame 2 - click far outside. Modal should NOT dismiss.
    {
        const click_outside: InputSnapshot = .{
            .mouse_pos = .{ 0, 0 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_outside, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        try expect(ui.isPopupOpen("modal_test"));
        h.close();
    }
}

test " beginMainMenuBar / endMainMenuBar lifecycle" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    try expect(!ctx.in_menu_bar);
    try expect(ui.beginMainMenuBar());
    try expect(ctx.in_menu_bar);
    try expect(ctx.main_menu_bar_win != null);
    // Bar fills canvas width.
    try expectEqual(@as(f32, 800), ctx.main_menu_bar_win.?.size[0]);
    try expectEqual(ctx.style.menu_bar_height, ctx.main_menu_bar_win.?.size[1]);
    ui.endMainMenuBar();
    try expect(!ctx.in_menu_bar);
}

test " beginMenu inside menu bar - closed by default" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    _ = ui.beginMainMenuBar();
    defer ui.endMainMenuBar();
    // No click yet - beginMenu returns false.
    try expect(!ui.beginMenu("File"));
}

test "B3b: per-window beginMenuBar sets in_menu_bar + anchors on the current window" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    try expect(!ctx.in_menu_bar);
    if (ui.window("Doc", .{
        .initial_pos = .{ 100, 50 },
        .initial_size = .{ 300, 200 },
        .flags = .{ .menu_bar = true },
    })) |w| {
        defer w.close();
        const opened: bool = ui.beginMenuBar();
        try expect(opened);
        try expect(ctx.in_menu_bar);
        // Bar should sit just below the title at y = win.pos.y + title_bar_height.
        const expected_bottom: f32 = 50 + ctx.style.title_bar_height + ctx.style.menu_bar_height;
        try expectEqual(@as(f32, expected_bottom), ctx.menu_bar_bottom_y);
        // Cursor in canvas coords starts 8 px in from the window's left edge.
        try expectEqual(@as(f32, 108), ctx.menu_bar_cursor_x);
        ui.endMenuBar();
        try expect(!ctx.in_menu_bar);
    }
}

test "B3b: nesting beginMenuBar inside an open menu bar is a no-op" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    if (ui.window("Doc", .{
        .initial_pos = .{ 0, 0 },
        .initial_size = .{ 200, 100 },
        .flags = .{ .menu_bar = true },
    })) |w| {
        defer w.close();
        try expect(ui.beginMenuBar());
        defer ui.endMenuBar();
        // Second open call should bail.
        try expect(!ui.beginMenuBar());
    }
}

test "B3c: beginCombo returns false when closed (no click)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    if (ui.window("Doc", .{
        .initial_pos = .{ 0, 0 },
        .initial_size = .{ 300, 200 },
    })) |w| {
        defer w.close();
        // No click - should be closed.
        try expect(!ui.beginCombo("preset", "Low", .{}));
        // combo_scope must remain null when closed.
        try expect(ctx.combo_scope == null);
    }
}

test "B3c: clicking the closed row opens the combo + sets combo_scope" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Click somewhere likely inside the closed row of a combo
    // submitted at default cursor - text "preset" starts at the
    // window's top-left + padding, so the box is roughly at
    // (window_padding.x + 50, title_bar_height + window_padding.y).
    const click_input: InputSnapshot = .{
        .mouse_pos = .{ 30, 40 },
        .mouse_left_clicked = true,
    };
    const ui: Ui = ctx.beginFrameRaw(click_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    if (ui.window("Doc", .{
        .initial_pos = .{ 0, 0 },
        .initial_size = .{ 300, 200 },
    })) |w| {
        defer w.close();
        const opened: bool = ui.beginCombo("##c", "Preview", .{ .width = 200 });
        try expect(opened);
        try expect(ctx.combo_scope != null);
        if (opened) {
            ui.endCombo();
            try expect(ctx.combo_scope == null);
        }
    }
}

test " beginMenu opens popup on click + advances bar cursor" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    // Click inside the "File" button area (starts at x=8, padding 12 each side).
    const click_input: InputSnapshot = .{
        .mouse_pos = .{ 30, 10 },
        .mouse_left_clicked = true,
    };
    const ui: Ui = ctx.beginFrameRaw(click_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    _ = ui.beginMainMenuBar();
    const before_x: f32 = ctx.menu_bar_cursor_x;
    const opened: bool = ui.beginMenu("File");
    try expect(opened);
    if (opened) {
        ui.endMenu();
    }
    // Cursor advanced.
    try expect(ctx.menu_bar_cursor_x > before_x);
    ui.endMainMenuBar();
}

test " menuItem returns true on click + closes parent menu" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Click positions derived from style so future default tweaks
    // don't drift this test.  Previous version hard-coded
    // `(30, 10)` and `(30, 36)` against the old menu_bar_height=22
    // / window_padding=8 defaults; both shifted when the desktop-
    // default bump moved those to 28 / 10.
    const bar_click_y: f32 = ctx.style.menu_bar_height * 0.5;
    const item_click_y: f32 = ctx.style.menu_bar_height +
        ctx.style.window_padding[1] +
        ctx.style.frame_padding[1] + 2;
    const click_x: f32 = 30; // inside the "File" button regardless of font

    // Frame 1 - open the menu via click on its bar button.
    {
        const click_input: InputSnapshot = .{
            .mouse_pos = .{ click_x, bar_click_y },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        _ = ui.beginMainMenuBar();
        if (ui.beginMenu("File")) {
            ui.endMenu();
        }
        ui.endMainMenuBar();
    }
    // Frame 2 - menu is open. Click on a menu item inside it.
    // Popup is anchored at the bar button (x≈8, y=menu_bar_height).
    // First menuItem renders at (anchor.x + window_padding.x,
    // anchor.y + window_padding.y), so a click slightly inside
    // that origin lands on the first item.
    {
        const click_item: InputSnapshot = .{
            .mouse_pos = .{ click_x, item_click_y },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_item, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        _ = ui.beginMainMenuBar();
        var clicked: bool = false;
        if (ui.beginMenu("File")) {
            clicked = ui.menuItem("Open", .{ .shortcut = "Ctrl+O" });
            ui.endMenu();
        }
        ui.endMainMenuBar();
        try expect(clicked);
        // Menu auto-closed after the click.
        const bar_id: Id = ctx.main_menu_bar_win.?.id;
        const menu_id: Id = hashStr(bar_id, "File");
        try expect(!ctx.popup_open.contains(menu_id));
    }
}

// ============================================================================
// tests - tab bars + tab items + list boxes.
// ============================================================================

test " beginTabBar/endTabBar lifecycle creates persistent state" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    try expectEqual(@as(usize, 0), ctx.tab_bar_state.count());
    try expect(ui.beginTabBar("settings", .{}));
    try expectEqual(@as(usize, 1), ctx.tab_bar_stack.len);
    try expectEqual(@as(usize, 1), ctx.tab_bar_state.count());
    ui.endTabBar();
    try expectEqual(@as(usize, 0), ctx.tab_bar_stack.len);
}

test " first beginTabItem auto-selects when no active tab" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    // First tab - auto-selected, returns true.
    const a_active: bool = ui.beginTabItem("A", null, .{});
    if (a_active) {
        ui.endTabItem();
    }
    try expect(a_active);
    // Second tab - different id, NOT auto-selected.
    const b_active: bool = ui.beginTabItem("B", null, .{});
    if (b_active) {
        ui.endTabItem();
    }
    try expect(!b_active);
}

test " clicking a tab makes it active across frames" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    // Frame 1 - submit two tabs. A auto-selects. Capture tab B's
    // rect so frame 2 can click inside it.
    var b_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.beginTabBar("bar", .{});
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", null, .{})) {
            ui.endTabItem();
        }
        // After endTabItem("B"), last_item_rect is B's button rect.
        b_rect = h.win.layout.last_item_rect;
        ui.endTabBar();
        h.close();
    }

    // Frame 2 - click in the centre of B's known rect.
    {
        const click: InputSnapshot = .{
            .mouse_pos = .{ b_rect.x + b_rect.width / 2, b_rect.y + b_rect.height / 2 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.beginTabBar("bar", .{});
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", null, .{})) {
            ui.endTabItem();
        }
        ui.endTabBar();
        const bar_id: Id = hashStr(h.win.id, "bar");
        const b_id: Id = hashStr(bar_id, "B");
        const state: TabBarState = ctx.tab_bar_state.get(bar_id) orelse unreachable;
        h.close();
        try expectEqual(b_id, state.selected_id);
    }
}

test " tab close-X with *open=false hides the tab" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    var tab_open: bool = false;
    // *open=false → tab should silently skip itself, return false.
    try expect(!ui.beginTabItem("Closable", &tab_open, .{}));
}

// ---- Turn 411 tab-bg priority + lift geometry suite ---------------
//

// Sentinel pattern: pick three style colors that pack to distinct
// ColorU32 values so we can scan `w.draw_list.cmds` and identify
// which rect_filled came from which style slot. Helpers below.

/// Packed sentinel colors for the four tab states a paint can land
/// in. Returned from `turn411TabStyle` so tests can read each
/// state's color back from the draw list by exact-match comparison.
const TabSentinelColors = struct {
    tab_idle: ColorU32,
    tab_hovered: ColorU32,
    tab_active: ColorU32,
    separator: ColorU32,
};

fn turn411TabStyle(ctx: *UiContext) TabSentinelColors {
    // Distinct sentinel colors per state so a draw-list scan
    // tells us unambiguously which branch fired.
    ctx.style.tab = .{ .r = 0x11, .g = 0x11, .b = 0x11, .a = 0xFF };
    ctx.style.tab_hovered = .{ .r = 0x22, .g = 0x22, .b = 0x22, .a = 0xFF };
    ctx.style.tab_active = .{ .r = 0x33, .g = 0x33, .b = 0x33, .a = 0xFF };
    ctx.style.tab_separator = .{ .r = 0x44, .g = 0x44, .b = 0x44, .a = 0xFF };
    return .{
        .tab_idle = Color.toWire(ctx.style.tab),
        .tab_hovered = Color.toWire(ctx.style.tab_hovered),
        .tab_active = Color.toWire(ctx.style.tab_active),
        .separator = Color.toWire(ctx.style.tab_separator),
    };
}

/// Result of scanning the draw list for a specific rect color.
/// `idx` lets tests compare relative paint order; `rect` lets them
/// check geometry (e.g. the active-tab lift effect via height).
const FoundRectFilled = struct {
    idx: usize,
    rect: Rectangle,
};

/// Search the window's draw list for a rect_filled command matching
/// `col`. Returns the first match (lowest index) or null.
fn findRectFilled(w: *const Window, col: ColorU32) ?FoundRectFilled {
    for (w.draw_list.cmds.items, 0..) |c, i| {
        if (c != .rect_filled) {
            continue;
        }
        if (c.rect_filled.col == col) {
            return .{ .idx = i, .rect = c.rect_filled.rect };
        }
    }
    return null;
}

test "Turn 411: hovered idle tab paints with tab_hovered" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const cols: TabSentinelColors = turn411TabStyle(&ctx);
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    // First frame: submit A (auto-selects) + B. Capture B's rect.
    var b_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.beginTabBar("bar", .{});
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", null, .{})) {
            ui.endTabItem();
        }
        b_rect = h.win.layout.last_item_rect;
        ui.endTabBar();
        h.close();
    }

    // Second frame: mouse parked on B (B is NOT active — A is the
    // auto-selected one). Expect B's bg color = tab_hovered.
    const input: InputSnapshot = .{
        .mouse_pos = .{ b_rect.x + b_rect.width / 2, b_rect.y + b_rect.height / 2 },
    };
    const ui: Ui = ctx.beginFrameRaw(input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem();
    }
    if (ui.beginTabItem("B", null, .{})) {
        ui.endTabItem();
    }

    // The hovered-but-idle B tab queued a rect_filled in tab_hovered.
    try expect(findRectFilled(h.win, cols.tab_hovered) != null);
}

test "Turn 411: hover wins over active (active+hovered → tab_hovered, not tab_active)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const cols: TabSentinelColors = turn411TabStyle(&ctx);
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    // Frame 1: submit A (auto-selects) and capture its rect.
    var a_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.beginTabBar("bar", .{});
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        a_rect = h.win.layout.last_item_rect;
        ui.endTabBar();
        h.close();
    }

    // Frame 2: A is still active (selected_id == A), AND we park the
    // mouse inside A. The crucial check: bg should be tab_hovered
    // (press-feedback) NOT tab_active. Pre-turn-411 zimr did the
    // opposite priority and this is the regression alarm for that.
    const input: InputSnapshot = .{
        .mouse_pos = .{ a_rect.x + a_rect.width / 2, a_rect.y + a_rect.height / 2 },
    };
    const ui: Ui = ctx.beginFrameRaw(input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem();
    }

    // hovered-AND-active A: tab_hovered SHOULD appear, tab_active
    // should NOT. Two independent assertions for clarity.
    try expect(findRectFilled(h.win, cols.tab_hovered) != null);
    try expect(findRectFilled(h.win, cols.tab_active) == null);
}

test "Turn 411: active tab not hovered paints tab_active" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const cols: TabSentinelColors = turn411TabStyle(&ctx);
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    // Mouse far away from any tab; auto-selected A should paint
    // tab_active (steady-state "selected" color).
    const input: InputSnapshot = .{ .mouse_pos = .{ 5000, 5000 } };
    const ui: Ui = ctx.beginFrameRaw(input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem();
    }

    try expect(findRectFilled(h.win, cols.tab_active) != null);
}

test "Turn 411: separator paints BEFORE any tab bg (enables lift via geometry)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const cols: TabSentinelColors = turn411TabStyle(&ctx);
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem();
    }

    // The separator (drawn first in openTabBar) must appear in the
    // draw list at a lower index than the tab's bg (drawn during
    // beginTabItem). Pre-turn-411 the separator was drawn at
    // endTabBar time and ended up AFTER the tab bg — that order
    // meant the separator overdrew every tab's bottom 1px, making
    // the "lift effect" require a separate 1px overdraw slice.
    const sep: FoundRectFilled = findRectFilled(h.win, cols.separator) orelse
        return error.SeparatorMissing;
    const a_bg: FoundRectFilled = findRectFilled(h.win, cols.tab_active) orelse
        return error.TabBgMissing;
    try expect(sep.idx < a_bg.idx);
}

test "Turn 411: active tab bg covers full bar height; inactive trims 1px" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const cols: TabSentinelColors = turn411TabStyle(&ctx);
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 300 } };

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    defer ui.endTabBar();
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem(); // active
    }
    if (ui.beginTabItem("B", null, .{})) {
        ui.endTabItem(); // idle
    }

    const a_bg: FoundRectFilled = findRectFilled(h.win, cols.tab_active) orelse
        return error.ActiveBgMissing;
    const b_bg: FoundRectFilled = findRectFilled(h.win, cols.tab_idle) orelse
        return error.IdleBgMissing;
    // Inactive tab's bg is exactly 1px shorter than active's. This
    // is the geometric encoding of the "active tab merges with the
    // content below" lift effect — active covers the separator,
    // idle stops just above it.
    try expectEqual(@as(f32, 1.0), a_bg.rect.height - b_bg.rect.height);
}

test "tabs persisted across frames" {
    // The new tab list (TabBarState.tabs) accumulates entries the
    // first time each tab is seen, then re-finds them on subsequent
    // frames. Pin both: 3 tabs submitted twice → exactly 3 entries.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var bar_id: Id = 0;
    var frame: u32 = 0;
    while (frame < 2) : (frame += 1) {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (frame == 0) bar_id = hashStr(h.win.id, "bar");
        if (ui.beginTabItem("A", null, .{})) ui.endTabItem();
        if (ui.beginTabItem("B", null, .{})) ui.endTabItem();
        if (ui.beginTabItem("C", null, .{})) ui.endTabItem();
    }
    const state: *TabBarState = ctx.tab_bar_state.getPtr(bar_id) orelse unreachable;
    try expectEqual(@as(usize, 3), state.tabs.items.len);
}

test "tab GC removes tabs the caller stops submitting" {
    // Frame 1 submits A+B+C; frame 2 submits only A. After frame 2's
    // endTabBar, B and C should be GC'd (last_frame_active < frame_count).
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var bar_id: Id = 0;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        bar_id = hashStr(h.win.id, "bar");
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("C", null, .{})) {
            ui.endTabItem();
        }
    }
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        // B and C deliberately not submitted.
    }
    const state: *TabBarState = ctx.tab_bar_state.getPtr(bar_id) orelse unreachable;
    try expectEqual(@as(usize, 1), state.tabs.items.len);
    try expectEqualStrings("A", state.tabs.items[0].label_buf[0..state.tabs.items[0].label_len]);
}

test "set_selected force-selects a tab" {
    // The first tab auto-selects; set_selected on the second tab
    // queues it for selection at endTabBar.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    if (ui.beginTabItem("A", null, .{})) {
        ui.endTabItem();
    }
    // .set_selected on B should ROUTE selection through next_selected_id.
    if (ui.beginTabItem("B", null, .{ .set_selected = true })) {
        ui.endTabItem();
    }
    ui.endTabBar();
    const bar_id: Id = hashStr(h.win.id, "bar");
    const b_id: Id = hashStr(bar_id, "B");
    const state: *TabBarState = ctx.tab_bar_state.getPtr(bar_id) orelse unreachable;
    try expectEqual(b_id, state.selected_id);
}

test "leading + trailing tabs pin to their edges" {
    // Layout check: leading tab gets x_offset at bar.x; middle tabs
    // follow after; trailing tab is at the right edge. We don't
    // store x_offset per-tab in the state today (computed at render
    // time), so this test exercises the cursor advancement through
    // the bar frame: after submitting one leading + one middle, the
    // middle_cursor_x should be greater than the leading_cursor_x's
    // starting position.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{ .initial_size = .{ 600, 400 } }) orelse unreachable;
    defer h.close();
    _ = ui.beginTabBar("bar", .{});
    if (ui.beginTabItem("Lead", null, .{ .leading = true })) {
        ui.endTabItem();
    }
    if (ui.beginTabItem("Mid", null, .{})) {
        ui.endTabItem();
    }
    if (ui.beginTabItem("Trail", null, .{ .trailing = true })) {
        ui.endTabItem();
    }
    // Inspect the frame's cursors before endTabBar pops it.
    const frame: TabBarFrame = ctx.tab_bar_stack.items[ctx.tab_bar_stack.len - 1];
    try expect(frame.leading_cursor_x > frame.rect.x);
    try expect(frame.middle_cursor_x > frame.leading_cursor_x);
    try expect(frame.trailing_cursor_x < frame.rect.x + frame.rect.width);
    ui.endTabBar();
}

test "middle-click closes tab (default behavior)" {
    // Two frames: first to lay out tabs + capture rects, second to
    // place mouse on tab B and middle-click. Expect tab_open to be
    // false after endTabBar finalizes the close.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var b_center: Vec2 = .{ 0, 0 };
    var a_open: bool = true;
    var b_open: bool = true;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{ .initial_size = .{ 600, 400 } }) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (ui.beginTabItem("A", &a_open, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", &b_open, .{})) {
            ui.endTabItem();
        }
        // Capture B's screen rect: last_item_rect on the window after
        // submitting B is B's button.
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        b_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }
    {
        const snapshot: InputSnapshot = .{ .mouse_pos = b_center, .mouse_middle_clicked = true };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (ui.beginTabItem("A", &a_open, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", &b_open, .{})) {
            ui.endTabItem();
        }
    }
    // endTabBar should have written b_open = false on the close finalize.
    try expect(a_open);
    try expect(!b_open);
}

test "no_close_with_middle_mouse_button suppresses middle-click close" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var b_center: Vec2 = .{ 0, 0 };
    var b_open: bool = true;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{ .initial_size = .{ 600, 400 } }) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{ .no_close_with_middle_mouse_button = true });
        defer ui.endTabBar();
        if (ui.beginTabItem("B", &b_open, .{})) {
            ui.endTabItem();
        }
        const r: Rectangle = ctx.current_window.?.layout.last_item_rect;
        b_center = .{ r.x + r.width / 2, r.y + r.height / 2 };
    }
    {
        const snapshot: InputSnapshot = .{ .mouse_pos = b_center, .mouse_middle_clicked = true };
        const ui: Ui = ctx.beginFrameRaw(snapshot, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{ .no_close_with_middle_mouse_button = true });
        defer ui.endTabBar();
        if (ui.beginTabItem("B", &b_open, .{})) {
            ui.endTabItem();
        }
    }
    // Suppression worked: b_open remains true despite middle-click.
    try expect(b_open);
}

test "id_stack push isolates per-tab widget IDs" {
    // Two tabs with same-labeled buttons should hash to different
    // ids when the tab id is on the stack.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var id_in_a: Id = 0;
    var id_in_b: Id = 0;
    // Frame 1: A active, capture button id.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (ui.beginTabItem("A", null, .{})) {
            defer ui.endTabItem();
            _ = ui.button("Save", .{});
            id_in_a = ctx.current_window.?.layout.last_item_id;
        }
    }
    // Frame 2: programmatically switch to B; capture button id.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.beginTabBar("bar", .{});
        defer ui.endTabBar();
        if (ui.beginTabItem("A", null, .{})) {
            ui.endTabItem();
        }
        if (ui.beginTabItem("B", null, .{ .set_selected = true })) {
            defer ui.endTabItem();
            _ = ui.button("Save", .{});
            id_in_b = ctx.current_window.?.layout.last_item_id;
        }
    }
    try expect(id_in_a != 0);
    try expect(id_in_b != 0);
    try expect(id_in_a != id_in_b);
}

test " beginListBox/endListBox saves+restores cursor" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    const before: Vec2 = h.win.layout.cursor_pos;
    try expect(ui.beginListBox("items", .{ 200, 80 }));
    // After's child-as-Window refactor, the host window's
    // layout is NOT mutated during the listBox scope - the list box
    // has its own Window with its own layout. The current_window
    // pointer flips to the list box's Window. Verify it.
    const list_box: *Window = ctx.current_window.?;
    try expect(list_box != h.win);
    try expect(list_box.flags.is_child);
    // The list box's cursor starts at its inner_origin (inside the
    // frame_padding), distinct from the host's cursor which still
    // points where it was before beginListBox.
    try expect(list_box.layout.cursor_pos[0] != before[0] or list_box.layout.cursor_pos[1] != before[1]);
    try expectEqual(before, h.win.layout.cursor_pos);
    ui.endListBox();
    // After endListBox, host's cursor advanced past the box (Y > before.y).
    try expect(h.win.layout.cursor_pos[1] >= before[1]);
}

test " listBox helper returns true on selection change" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const items = &[_][]const u8{ "Alpha", "Beta", "Gamma" };

    // Frame 1 - render the list box; current = 0. No click → no change.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        var current: i32 = 0;
        const changed: bool = ui.listBox("items", &current, items);
        h.close();
        try expect(!changed);
        try expectEqual(@as(i32, 0), current);
    }
}

// ============================================================================
// tests - progress bar, vertical slider, text-entry numeric
// inputs, color button, image button.
// ============================================================================

test " progressBar clamps fraction + records last_item_rect" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    const before_y: f32 = h.win.layout.cursor_pos[1];

    // Out-of-range fractions should clamp visually but not crash;
    // the item rect should reflect requested size.
    const requested_size: Vec2 = .{ 200, 20 };
    ui.progressBar(2.5, requested_size, ""); // > 1, clamps to 1
    try expectEqual(@as(f32, 200), h.win.layout.last_item_rect.width);
    try expectEqual(@as(f32, 20), h.win.layout.last_item_rect.height);

    ui.progressBar(-0.5, requested_size, "loading"); // < 0, clamps to 0
    // Cursor should have advanced after both calls.
    try expect(h.win.layout.cursor_pos[1] > before_y);
}

test " vSlider drives value when clicked + dragged" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 200, 200 } };
    var v: f32 = 0.5;

    // Frame 1 - render so we know the bar's rect.
    var bar_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.vSlider("vol", .{ 20, 100 }, &v, .{ .min = 0, .max = 1 });
        bar_rect = h.win.layout.last_item_rect;
        h.close();
    }

    // Frame 2 - click at the TOP of the bar. Top = max in vertical
    // slider convention, so v should jump close to opts.max=1.0.
    {
        const click_top: InputSnapshot = .{
            .mouse_pos = .{ bar_rect.x + bar_rect.width / 2, bar_rect.y + 2 },
            .mouse_left_clicked = true,
            .mouse_left_down = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_top, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.vSlider("vol", .{ 20, 100 }, &v, .{ .min = 0, .max = 1 });
        h.close();
    }
    // After the click+drag, value should have moved upward toward 1.
    try expect(v > 0.9);
}

test " parseScalar accepts ints, floats, scientific notation; rejects garbage" {
    try expectEqual(@as(?f32, 3.14), parseScalar(f32, "3.14"));
    try expectEqual(@as(?f32, -2.5), parseScalar(f32, "-2.5"));
    try expectEqual(@as(?f32, 1.5e3), parseScalar(f32, "1.5e3"));
    try expectEqual(@as(?i32, 42), parseScalar(i32, "42"));
    try expectEqual(@as(?i32, -100), parseScalar(i32, "-100"));
    // Whitespace stripped.
    try expectEqual(@as(?i32, 7), parseScalar(i32, "  7  "));
    // Empty + garbage return null.
    try expectEqual(@as(?f32, null), parseScalar(f32, ""));
    try expectEqual(@as(?f32, null), parseScalar(f32, "abc"));
    try expectEqual(@as(?i32, null), parseScalar(i32, "1.5"));
}

test " inputFloat focus seeds the edit buffer" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 300, 200 } };
    var v: f32 = 1.25;

    // Frame 1 - render unfocused, capture box rect.
    var box: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputFloat("speed", &v, .{ .fmt = "{d:.3}" });
        box = h.win.layout.last_item_rect;
        h.close();
    }

    // Frame 2 - click inside box to focus. Buffer should be seeded
    // with "1.250" and active_id matches the input's id.
    {
        const click: InputSnapshot = .{
            .mouse_pos = .{ box.x + box.width / 2, box.y + box.height / 2 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputFloat("speed", &v, .{ .fmt = "{d:.3}" });
        h.close();
        const expected_id: Id = hashStr(h.win.id, "speed");
        try expectEqual(expected_id, ctx.active_id);
        const seeded: []const u8 = ctx.scalar_edit_buf[0..ctx.scalar_edit_len];
        try expectEqualStrings("1.250", seeded);
    }
}

test " inputInt commits parsed value on Enter" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 300, 200 } };
    var v: i32 = 5;

    // Frame 1 - focus the input.
    var box: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputInt("count", &v, .{ .fmt = "{d}" });
        box = h.win.layout.last_item_rect;
        h.close();
    }
    // Frame 2 - click + start typing "42".
    {
        var inp: InputSnapshot = .{
            .mouse_pos = .{ box.x + box.width / 2, box.y + box.height / 2 },
            .mouse_left_clicked = true,
        };
        inp.chars_typed[0] = '4';
        inp.chars_typed[1] = '2';
        inp.chars_typed_count = 2;
        const ui: Ui = ctx.beginFrameRaw(inp, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputInt("count", &v, .{ .fmt = "{d}" });
        h.close();
        // Value still 5 - no commit yet.
        try expectEqual(@as(i32, 5), v);
    }
    // Frame 3 - Enter commits.
    {
        var inp: InputSnapshot = .{
            .mouse_pos = .{ box.x + box.width / 2, box.y + box.height / 2 },
        };
        inp.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;
        const ui: Ui = ctx.beginFrameRaw(inp, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        const changed: bool = ui.inputInt("count", &v, .{ .fmt = "{d}" });
        h.close();
        // The buffer was "5" + "42" = "542" because the seed-on-focus
        // path puts the existing value FIRST, then the typed chars
        // appended. Verify whatever the buffer parses to.
        try expect(changed);
        try expectEqual(@as(i32, 542), v);
        // Active focus dropped after commit.
        try expectEqual(@as(Id, 0), ctx.active_id);
    }
}

test " colorButton click returns true; hover sets tooltip" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 200, 200 } };
    var col: Color = .{ .r = 200, .g = 100, .b = 50, .a = 255 };

    // Frame 1 - render to capture rect.
    var sw: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.colorButton("##bg", &col, .{ 24, 24 });
        sw = h.win.layout.last_item_rect;
        h.close();
    }
    // Frame 2 - click in centre.
    {
        const click: InputSnapshot = .{
            .mouse_pos = .{ sw.x + sw.width / 2, sw.y + sw.height / 2 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        const clicked: bool = ui.colorButton("##bg", &col, .{ 24, 24 });
        try expect(clicked);
        // Hover this frame should also fire the tooltip path.
        try expect(ctx.pending_tooltip != null);
        h.close();
    }
}

test " imageButton dispatch on Texture returns false when not clicked" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    // A "fake" texture (id 0 is fine - we never actually sample it
    // because eager_mode is off and the draw command is queued).
    const tex: types.Texture = .{
        .id = 0,
        .width = 32,
        .height = 32,
        .mipmaps = 1,
        .format = 0,
    };
    const clicked: bool = ui.imageButton("##icon", tex, .{ 32, 32 });
    try expect(!clicked);
    // last_item_rect should reflect the requested 32x32.
    try expectEqual(@as(f32, 32), h.win.layout.last_item_rect.width);
    try expectEqual(@as(f32, 32), h.win.layout.last_item_rect.height);
}
test " inputFloat on commit rejects garbage + keeps prior value" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 200 } };
    var v: f32 = 9.0;

    // Frame 1 - render + capture rect.
    var box_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputFloat("x", &v, .{});
        box_rect = h.win.layout.last_item_rect;
        h.close();
    }
    // Frame 2 - click to focus.
    {
        const click_input: InputSnapshot = .{
            .mouse_pos = .{ box_rect.x + 5, box_rect.y + 5 },
            .mouse_left_clicked = true,
        };
        const ui: Ui = ctx.beginFrameRaw(click_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.inputFloat("x", &v, .{});
        h.close();
    }
    // Frame 3 - type "++--", commit on Enter - buffer fails parseFloat,
    // value should stay at 9.0.
    {
        var typed: [16]u32 = undefined;
        typed[0] = '+';
        typed[1] = '+';
        typed[2] = '-';
        typed[3] = '-';
        var type_input: InputSnapshot = .{
            .mouse_pos = .{ box_rect.x + 5, box_rect.y + 5 },
            .chars_typed = typed,
            .chars_typed_count = 4,
        };
        type_input.keys[@intFromEnum(KeyCode.enter)].pressed_this_frame = true;
        const ui: Ui = ctx.beginFrameRaw(type_input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        const changed: bool = ui.inputFloat("x", &v, .{});
        h.close();
        try expect(!changed);
        try expectEqual(@as(f32, 9.0), v);
    }
}

// ============================================================================
// tests - editStruct dispatch. Each test pins one path
// through `editStructImpl` / `editFieldDispatch`: bool, float, int,
// enum, nested struct, color array, opts.skip, opts.label.
// ============================================================================

test " editStruct compiles + runs over a flat struct" {
    const Settings = struct {
        enabled: bool = true,
        speed: f32 = 1.0,
        iterations: i32 = 100,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    // Pure render frame - no input changes - so editStruct must NOT
    // claim anything changed. Compiling at all proves the dispatch
    // covers bool/float/int.
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    // Field values unchanged.
    try expect(s.enabled);
    try expectEqual(@as(f32, 1.0), s.speed);
    try expectEqual(@as(i32, 100), s.iterations);
}

test " editStructOpts.skip excludes the field from the editor" {
    const Settings = struct {
        speed: f32 = 1.0,
        password: []const u8 = "secret",
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    // skip suppresses dispatch for that field - which means it
    // contributes neither rendering nor cursor advancement. We
    // can't directly observe non-rendering, but we CAN verify that
    // the skipped field is not reflected anywhere.
    const changed: bool = ui.editStructOpts(&s, .{
        .password = .{ .skip = true },
    });
    try expect(!changed);
}

test " editStructOpts opts.min+opts.max routes float to slider" {
    const Settings = struct {
        speed: f32 = 0.5,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    // With min+max present, the dispatch should route through
    // slider - which records last_item_id != 0. inputFloat would
    // also record an id, so we instead verify by behaviour: a
    // slider with no input change leaves the value alone.
    const changed: bool = ui.editStructOpts(&s, .{
        .speed = .{ .min = 0.0, .max = 1.0 },
    });
    try expect(!changed);
    try expectEqual(@as(f32, 0.5), s.speed);
}

test " editStruct on enum field renders combo with current tag" {
    const Mode = enum { idle, running, paused };
    const Settings = struct {
        mode: Mode = .running,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    try expectEqual(Mode.running, s.mode);
}

test " editStruct recurses into nested struct under tree node" {
    const Inner = struct {
        x: f32 = 1.0,
        y: f32 = 2.0,
    };
    const Outer = struct {
        name: bool = true,
        inner: Inner = .{},
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Outer = .{};
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    // Inner's fields are present but tree node defaults to closed.
    // Compilation alone proves the recursive dispatch path works.
    try expectEqual(@as(f32, 1.0), s.inner.x);
}

test " editStruct on [3]f32 named 'color' routes to colorEdit" {
    const Settings = struct {
        bg_color: [3]f32 = .{ 0.5, 0.6, 0.7 },
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    // Compiles + runs - confirms colorEdit dispatch path is reachable
    // for array fields whose name matches "color".
    try expectEqual(@as(f32, 0.5), s.bg_color[0]);
}

test " editStructOpts.label overrides the field name" {
    const Settings = struct {
        x: f32 = 0.5,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    // The override is purely cosmetic - verify the call compiles
    // and the field hashing routes through the label string.
    const changed: bool = ui.editStructOpts(&s, .{
        .x = .{ .label = "X coordinate" },
    });
    try expect(!changed);
}

test " editStruct on []const u8 field renders read-only text" {
    const Settings = struct {
        title: []const u8 = "Demo",
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    // String fields are display-only for now - never report changed.
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    try expectEqualStrings("Demo", s.title);
}

test "P1.3: BoundedString set/asSlice round-trip" {
    var bs: BoundedString(32) = .{};
    try expectEqual(@as(usize, 0), bs.len);
    try expectEqualStrings("", bs.asSlice());

    const n: usize = bs.set("Hello, world!");
    try expectEqual(@as(usize, 13), n);
    try expectEqual(@as(usize, 13), bs.len);
    try expectEqualStrings("Hello, world!", bs.asSlice());

    // Truncation when input exceeds capacity.
    var small: BoundedString(4) = .{};
    const truncated: usize = small.set("abcdefgh");
    try expectEqual(@as(usize, 4), truncated);
    try expectEqualStrings("abcd", small.asSlice());
}

test "P1.3: BoundedString capacity decl is comptime" {
    try expectEqual(@as(usize, 16), BoundedString(16).capacity);
    try expectEqual(@as(usize, 64), BoundedString(64).capacity);
    try expectEqual(@as(usize, 16), BoundedString(16).__is_bounded_string);
}

test "P1.3: editStruct on a BoundedString field renders without error" {
    const Settings = struct {
        player_name: BoundedString(32) = .{},
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var s: Settings = .{};
    _ = s.player_name.set("Hero");
    // No focus, no edit input - text doesn't change; routing through
    // inputText compiles and dispatches without panicking.
    const changed: bool = ui.editStruct(&s);
    try expect(!changed);
    try expectEqualStrings("Hero", s.player_name.asSlice());
}

test "P1.3: dispatcher detects BoundedString before generic struct recursion" {
    // A BoundedString nested in a parent struct should NOT recurse
    // into a "buf" / "len" tree node - it should route to inputText
    // as a leaf widget. Inspectable by checking that editStruct
    // doesn't crash AND the len/buf weren't surfaced as separate
    // sub-tree widgets (the test for that is compile-time: the inline
    // for over fields would have picked them up if dispatched as a
    // generic struct, but the BoundedString check intercepts first).
    const Wrap = struct {
        name: BoundedString(8) = .{},
        other: i32 = 0,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var w: Wrap = .{};
    _ = w.name.set("hi");
    _ = ui.editStruct(&w);
    // Name preserved through the dispatch path; capacity-aware len
    // didn't get overwritten by a recursive descent.
    try expectEqual(@as(usize, 2), w.name.len);
    try expectEqualStrings("hi", w.name.asSlice());
}

test "P2.1: debugLogPush appends events with frame + kind + message" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    ctx.frame_count = 42;

    try expectEqual(@as(usize, 0), ctx.debug_log.len);
    debugLogPush(&ctx, .popup_opened, "id={x} str='ctx'", .{0xdeadbeef});
    try expectEqual(@as(usize, 1), ctx.debug_log.len);

    const ev: *const DebugEvent = &ctx.debug_log.slice()[0];
    try expectEqual(@as(u32, 42), ev.frame);
    try expectEqual(DebugEventKind.popup_opened, ev.kind);
    try expectEqualStrings("id=deadbeef str='ctx'", ev.messageSlice());

    // debugLogSlice mirrors the populated prefix.
    const slice: []const DebugEvent = debugLogSlice(&ctx);
    try expectEqual(@as(usize, 1), slice.len);
    try expectEqualStrings("id=deadbeef str='ctx'", slice[0].messageSlice());
}

test "P2.1: debugLog ring drops oldest event when at cap" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Fill to cap. Use the index as the frame number so we can
    // verify which events landed.
    var i: u32 = 0;
    while (i < debug_log_cap) : (i += 1) {
        ctx.frame_count = i;
        debugLogPush(&ctx, .item_activated, "evt-{d}", .{i});
    }
    try expectEqual(debug_log_cap, ctx.debug_log.len);
    // First event should be evt-0, last should be evt-(CAP-1).
    const ring: []const DebugEvent = ctx.debug_log.constSlice();
    try expectEqualStrings("evt-0", ring[0].messageSlice());
    var expected_tail_buf: [32]u8 = undefined;
    const expected_tail: []const u8 = bufPrint(
        &expected_tail_buf,
        "evt-{d}",
        .{debug_log_cap - 1},
    ) catch unreachable;
    try expectEqualStrings(
        expected_tail,
        ring[debug_log_cap - 1].messageSlice(),
    );

    // One more push: should drop evt-0 (orderedRemove(0)) and append
    // the new event at the tail.
    ctx.frame_count = 999;
    debugLogPush(&ctx, .item_activated, "overflow", .{});

    try expectEqual(debug_log_cap, ctx.debug_log.len); // still saturated
    const ring_after: []const DebugEvent = ctx.debug_log.constSlice();
    try expectEqualStrings("evt-1", ring_after[0].messageSlice());
    try expectEqualStrings("overflow", ring_after[debug_log_cap - 1].messageSlice());
    try expectEqual(@as(u32, 999), ring_after[debug_log_cap - 1].frame);
}

test "P2.1: debugLogPush truncates messages longer than the inline buffer" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Build a payload that exceeds debug_log_msg_max (128 bytes).
    const long_input: [200]u8 = @splat('x');
    debugLogPush(&ctx, .lint_warning, "{s}", .{long_input[0..]});

    try expectEqual(@as(usize, 1), ctx.debug_log.len);
    // bufPrint hit NoSpaceLeft → message_len recorded as MAX.
    const first: *const DebugEvent = &ctx.debug_log.slice()[0];
    try expectEqual(@as(u8, debug_log_msg_max), first.message_len);
    // The buffer carries the truncated prefix.
    for (first.messageSlice()) |b| {
        try expectEqual(@as(u8, 'x'), b);
    }
}

test "P2.2: Metrics defaults are zero / empty history" {
    const m: Metrics = .{};
    try expectEqual(@as(u32, 0), m.frame_count);
    try expectEqual(@as(f32, 0), m.frame_time_ms);
    try expectEqual(@as(u8, 0), m.history_head);
    try expectEqual(@as(u32, 0), m.cmd_count);
    try expectEqual(@as(u32, 0), m.drawlist_count);
    try expectEqual(@as(u32, 0), m.windows_active);
    try expectEqual(@as(u32, 0), m.windows_hovered);
    try expectEqual(@as(Id, 0), m.last_active_widget_id);
    try expectEqual(metrics_history_len, m.frame_time_ms_history.len);
    for (m.frame_time_ms_history) |sample| {
        try expectEqual(@as(f32, 0), sample);
    }
}

test "P2.2: metricsCollectAtEndFrame populates frame/window/active fields" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Simulate a frame's worth of state without actually running
    // beginFrame/endFrame (which require a runtime GlState and a
    // Frame wrapper). metricsCollectAtEndFrame is a pure read of
    // ctx state into ctx.metrics.
    ctx.frame_count = 7;
    ctx.input.delta_time = 0.01666; // ≈ 60 fps
    ctx.hovered_window_id = 0xabcd;
    ctx.active_id = 0xfeedface;

    metricsCollectAtEndFrame(&ctx);

    try expectEqual(@as(u32, 7), ctx.metrics.frame_count);
    try expectApproxEqAbs(@as(f32, 16.66), ctx.metrics.frame_time_ms, 0.01);
    try expectEqual(@as(u32, 0), ctx.metrics.windows_active); // frame_windows.len == 0
    try expectEqual(@as(u32, 1), ctx.metrics.windows_hovered);
    try expectEqual(@as(Id, 0xfeedface), ctx.metrics.last_active_widget_id);
    // History ring advanced by one.
    try expectEqual(@as(u8, 1), ctx.metrics.history_head);
    try expectApproxEqAbs(
        @as(f32, 16.66),
        ctx.metrics.frame_time_ms_history[0],
        0.01,
    );
}

test "P2.2: history ring wraps after metrics_history_len samples" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Push LEN+1 samples; head should land at index 1, NOT
    // metrics_history_len (which would overflow u8 anyway).
    var i: u32 = 0;
    while (i < metrics_history_len + 1) : (i += 1) {
        ctx.frame_count = i;
        ctx.input.delta_time = float(i) * 0.001;
        metricsCollectAtEndFrame(&ctx);
    }
    // After LEN+1 writes, head is at 1. The sample at history[0]
    // is the LAST write (the LEN-th sample, i.e. frame LEN); the
    // sample at history[1] would be the FIRST write (frame 0) if
    // it had survived — but it was overwritten on the (LEN+1)th
    // write which lands at index LEN%LEN=0. Therefore:
    //   - history[0] should be the sample for frame LEN (delta = LEN ms)
    //   - history[1] should be the sample for frame 1 (delta = 1 ms)
    try expectEqual(@as(u8, 1), ctx.metrics.history_head);
    try expectApproxEqAbs(
        float(metrics_history_len),
        ctx.metrics.frame_time_ms_history[0],
        0.01,
    );
    try expectApproxEqAbs(
        @as(f32, 1),
        ctx.metrics.frame_time_ms_history[1],
        0.01,
    );
}

test "P2.2: cmd_count and drawlist_count accumulate via drawlist helpers" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Build two synthetic draw lists with different cmd counts.
    var dl_a: DrawList = .{};
    defer dl_a.deinit(std.testing.allocator);
    try dl_a.cmds.append(
        std.testing.allocator,
        .{ .rect_filled = .{ .rect = .{ .x = 0, .y = 0, .width = 1, .height = 1 }, .col = 0 } },
    );
    try dl_a.cmds.append(
        std.testing.allocator,
        .{ .rect_filled = .{ .rect = .{ .x = 1, .y = 1, .width = 1, .height = 1 }, .col = 0 } },
    );

    var dl_b: DrawList = .{};
    defer dl_b.deinit(std.testing.allocator);
    try dl_b.cmds.append(
        std.testing.allocator,
        .{ .rect_filled = .{ .rect = .{ .x = 2, .y = 2, .width = 1, .height = 1 }, .col = 0 } },
    );

    // Pretend the render loop just kicked off.
    metricsResetRenderCounters(&ctx);
    try expectEqual(@as(u32, 0), ctx.metrics.cmd_count);
    try expectEqual(@as(u32, 0), ctx.metrics.drawlist_count);

    // Accumulate both lists.
    metricsAccumulateDrawList(&ctx, &dl_a);
    metricsAccumulateDrawList(&ctx, &dl_b);

    try expectEqual(@as(u32, 3), ctx.metrics.cmd_count);
    try expectEqual(@as(u32, 2), ctx.metrics.drawlist_count);

    // A second reset zeros both counters again.
    metricsResetRenderCounters(&ctx);
    try expectEqual(@as(u32, 0), ctx.metrics.cmd_count);
    try expectEqual(@as(u32, 0), ctx.metrics.drawlist_count);
}

test "P2.2: last_active_widget_id sticks across frames with no active widget" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Frame 1: widget gets activated.
    ctx.active_id = 0xdeadbeef;
    metricsCollectAtEndFrame(&ctx);
    try expectEqual(@as(Id, 0xdeadbeef), ctx.metrics.last_active_widget_id);

    // Frame 2: nothing active, but last_active should persist
    // (the user might want to inspect "what was the last thing
    // I clicked").
    ctx.active_id = 0;
    metricsCollectAtEndFrame(&ctx);
    try expectEqual(@as(Id, 0xdeadbeef), ctx.metrics.last_active_widget_id);

    // Frame 3: new widget activated → replaces.
    ctx.active_id = 0x1234abcd;
    metricsCollectAtEndFrame(&ctx);
    try expectEqual(@as(Id, 0x1234abcd), ctx.metrics.last_active_widget_id);
}

// ============================================================================
// — tactical lint asserts
// ============================================================================
// These tests verify the lint sites are reachable without crashing
// and that the host call path runs to completion. The actual
// `std.log.warn` output isn't captured here (would need a custom
// log handler); the value is in pinning that the lint paths
// don't accidentally pull in any test-only types or panic.

test "#1: slider with min >= max doesn't crash (lint fires once)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var v: f32 = 0.5;
    // Degenerate range (min == max). Lint should fire (writes to
    // stderr via std.log.warn) but the slider should still
    // early-return false because we're outside a window.
    const changed: bool = u.slider("test", &v, .{ .min = 1.0, .max = 1.0 });
    try expectEqual(false, changed);
    // Subsequent calls must continue to work (no state corruption).
    const changed2: bool = u.slider("test", &v, .{ .min = 0.0, .max = 1.0 });
    try expectEqual(false, changed2);
}

test "#2: drag with min > max doesn't crash (== is no-clamp sentinel)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    var v: f32 = 0.0;
    // min == max is the documented "disable clamping" sentinel —
    // this should NOT trigger the lint.
    const changed1: bool = u.drag("ok", &v, .{ .min = 0, .max = 0 });
    try expectEqual(false, changed1);
    // min > max is inverted — lint should fire.
    const changed2: bool = u.drag("bad", &v, .{ .min = 10, .max = 5 });
    try expectEqual(false, changed2);
}

test "#3: table column overflow lint reachable" {
    // 8 columns at min_w = 2*pad+8 ≈ 24px each = 192px minimum,
    // outer rect 100px wide → overflow. We build the TableState
    // directly because beginTable needs a live window.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    const cell_pad_x: f32 = 8;
    const min_w: f32 = 2 * cell_pad_x + 8; // 24px
    var ts: TableState = std.mem.zeroes(TableState);
    ts.id = 0xdeadbeef;
    ts.n_columns = 8;
    ts.columns_set_up = 8;
    ts.outer_rect = .{ .x = 0, .y = 0, .width = 100, .height = 200 };
    ts.opts = .{ .cell_padding_x = cell_pad_x, .cell_padding_y = 4 };
    var i: u32 = 0;
    while (i < 8) : (i += 1) {
        ts.columns[i] = .{};
    }
    computeTableColumnLayout(&ctx, &ts);
    // Every column floors at min_w, last col x + width > outer right.
    const final_right: f32 = ts.columns[7].x + ts.columns[7].width;
    try expect(final_right > ts.outer_rect.x + ts.outer_rect.width);
    // Each column got at least the minimum.
    for (ts.columns[0..8]) |c| {
        try expect(c.width >= min_w - 0.01);
    }
}

test "#4: double openPopup in same frame doesn't crash" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u: Ui = .{ .ctx = &ctx };
    ctx.frame_count = 5;
    // First call — primes the map.
    u.openPopup("ctx");
    // Second call same frame — lint should fire. Map entry should
    // STILL exist after; behavior is otherwise unchanged.
    u.openPopup("ctx");
    const parent_id: Id = 0; // no current_window
    const expected_id: Id = hashStr(parent_id, "ctx");
    try expect(ctx.popup_open.contains(expected_id));
    // DebugLog should record both opens.
    const events: []const DebugEvent = debugLogSlice(&ctx);
    try expectEqual(@as(usize, 2), events.len);
    try expectEqual(DebugEventKind.popup_opened, events[0].kind);
    try expectEqual(DebugEventKind.popup_opened, events[1].kind);
}

test "#5: warnOnce dedup mechanism (same call site fires once)" {
    // The `warnOnce` dedup flag isn't directly observable from a
    // test (it's a per-call-site anonymous-struct global), so what
    // we CAN verify is that calling the same site repeatedly doesn't
    // crash and behavior is otherwise stable. This is a smoke test
    // for the helper itself.
    const utils = @import("utils.zig");
    var i: u32 = 0;
    while (i < 100) : (i += 1) {
        utils.warnOnce(@src(), "smoke-test iteration {d}", .{i});
    }
    // We reached here without panic. In a Debug build, std.log.warn
    // produced exactly ONE line on the first iteration; the other 99
    // were short-circuited by `dedup.fired = true`.
}

// ============================================================================
// P3 — Long-press → right-click synthesis
// ============================================================================
// Tests drive `applyLongPressSynthesis` directly with synthetic
// InputState + InputSnapshot pairs, bypassing the `beginFrame`
// plumbing (no Frame / GlState construction needed). Each test
// pins one contract.

test "P3: touch held past threshold fires synthetic right-click" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    var input_state: runtime.input.InputState = .{};
    // Touchdown at (100, 100). Mouse-left bridges to down via JS;
    // simulate that explicitly.
    runtime.input.pushTouchDown(&input_state, 42, 100, 100);
    runtime.input.pushMouseButtonDown(&input_state, 0);

    // Frame 1: 0ms elapsed. Touchdown observed; no synthesis yet.
    var snap1: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_left_down = true,
        .mouse_left_clicked = true,
        .delta_time = 0.0, // first frame
    };
    applyLongPressSynthesis(&ctx, &snap1, &input_state);
    try expectEqual(false, snap1.mouse_right_clicked);
    try expectEqual(true, snap1.mouse_left_down);
    try expectEqual(@as(i32, 42), ctx.long_press.touch_id);

    // Frame 2: simulate 600ms elapsed since touchdown — over threshold.
    // (Tracker initialised in frame 1, so accumulator is 0; bumping
    // delta_time accumulates from here.)
    var snap2: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_left_down = true,
        .delta_time = 0.6, // 600ms in seconds
    };
    applyLongPressSynthesis(&ctx, &snap2, &input_state);
    try expectEqual(true, snap2.mouse_right_clicked);
    try expectEqual(false, snap2.mouse_left_down);
    try expectEqual(true, ctx.long_press.just_fired_this_frame);
}

test "P3: drift > 6px cancels the long-press" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    var input_state: runtime.input.InputState = .{};
    runtime.input.pushTouchDown(&input_state, 7, 100, 100);
    runtime.input.pushMouseButtonDown(&input_state, 0);

    var snap1: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.0 };
    applyLongPressSynthesis(&ctx, &snap1, &input_state);

    // Frame 2: finger moved 20px down — far beyond drift threshold.
    runtime.input.pushTouchMove(&input_state, 7, 100, 120);
    var snap2: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.3 };
    applyLongPressSynthesis(&ctx, &snap2, &input_state);
    try expectEqual(false, snap2.mouse_right_clicked);

    // Frame 3: even after enough additional time, drift-cancel
    // sticks for the rest of the press.
    var snap3: InputSnapshot = .{
        .mouse_pos = .{ 100, 120 },
        .mouse_left_down = true,
        .delta_time = 0.5,
    };
    applyLongPressSynthesis(&ctx, &snap3, &input_state);
    try expectEqual(false, snap3.mouse_right_clicked);
}

test "P3: multi-touch (>1 finger) never synthesizes" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    var input_state: runtime.input.InputState = .{};
    // Two fingers down at once → pinch / pan gesture.
    runtime.input.pushTouchDown(&input_state, 1, 100, 100);
    runtime.input.pushTouchDown(&input_state, 2, 200, 100);
    runtime.input.pushMouseButtonDown(&input_state, 0);

    var snap1: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.0 };
    applyLongPressSynthesis(&ctx, &snap1, &input_state);
    try expectEqual(@as(i32, -1), ctx.long_press.touch_id);

    // Frame 2: even after long elapsed, no synthesis.
    var snap2: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.6 };
    applyLongPressSynthesis(&ctx, &snap2, &input_state);
    try expectEqual(false, snap2.mouse_right_clicked);
}

test "P3: desktop mouse hold doesn't trigger synthesis" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // No touches active — pure mouse press.
    var input_state: runtime.input.InputState = .{};
    runtime.input.pushMouseButtonDown(&input_state, 0);

    var snap1: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.6 };
    applyLongPressSynthesis(&ctx, &snap1, &input_state);
    try expectEqual(false, snap1.mouse_right_clicked);
    try expectEqual(true, snap1.mouse_left_down); // untouched
    try expectEqual(@as(i32, -1), ctx.long_press.touch_id);

    // Even after many ticks — no synthesis without a tracked touch.
    var snap2: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 1.0 };
    applyLongPressSynthesis(&ctx, &snap2, &input_state);
    try expectEqual(false, snap2.mouse_right_clicked);
}

test "P3: fired flag is one-shot — second frame doesn't re-fire" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    var input_state: runtime.input.InputState = .{};
    runtime.input.pushTouchDown(&input_state, 100, 50, 50);
    runtime.input.pushMouseButtonDown(&input_state, 0);

    // Tick 1: arm.
    var s1: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.0 };
    applyLongPressSynthesis(&ctx, &s1, &input_state);

    // Tick 2: cross threshold → fires.
    var s2: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.55 };
    applyLongPressSynthesis(&ctx, &s2, &input_state);
    try expectEqual(true, s2.mouse_right_clicked);
    try expectEqual(true, ctx.long_press.just_fired_this_frame);

    // Tick 3: still holding the same finger → no re-fire. The
    // `just_fired_this_frame` flag resets at the start of the call.
    var s3: InputSnapshot = .{
        .mouse_pos = .{ 50, 50 },
        // After fire, the caller might still set this true on the next
        // frame because the user hasn't lifted yet; tracker must
        // suppress re-fire regardless.
        .mouse_left_down = true,
        .delta_time = 0.02,
    };
    applyLongPressSynthesis(&ctx, &s3, &input_state);
    try expectEqual(false, s3.mouse_right_clicked);
    try expectEqual(false, ctx.long_press.just_fired_this_frame);
}

test "P3: touch lifted then re-pressed re-arms the tracker" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    var input_state: runtime.input.InputState = .{};
    // First press, drift-cancel.
    runtime.input.pushTouchDown(&input_state, 5, 100, 100);
    runtime.input.pushMouseButtonDown(&input_state, 0);

    var s1: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.0 };
    applyLongPressSynthesis(&ctx, &s1, &input_state);
    runtime.input.pushTouchMove(&input_state, 5, 200, 200); // drift
    var s2: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.1 };
    applyLongPressSynthesis(&ctx, &s2, &input_state);
    try expectEqual(@as(f32, -1), ctx.long_press.elapsed_ms);

    // Release the touch.
    runtime.input.pushTouchUp(&input_state, 5);
    runtime.input.pushMouseButtonUp(&input_state, 0);
    var s3: InputSnapshot = .{ .delta_time = 0.02 };
    applyLongPressSynthesis(&ctx, &s3, &input_state);
    try expectEqual(@as(i32, -1), ctx.long_press.touch_id);
    try expectEqual(@as(f32, 0), ctx.long_press.elapsed_ms); // re-armed

    // Fresh press with different id → fresh tracking.
    runtime.input.pushTouchDown(&input_state, 99, 50, 50);
    runtime.input.pushMouseButtonDown(&input_state, 0);
    var s4: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.0 };
    applyLongPressSynthesis(&ctx, &s4, &input_state);
    try expectEqual(@as(i32, 99), ctx.long_press.touch_id);

    var s5: InputSnapshot = .{ .mouse_left_down = true, .delta_time = 0.6 };
    applyLongPressSynthesis(&ctx, &s5, &input_state);
    try expectEqual(true, s5.mouse_right_clicked);
}

// ============================================================================
// — Chrome suppression flags
// ============================================================================
// Each flag's effect is observable via either the resulting Window
// state (flags propagated correctly) or the drawn cmd count
// (chrome elements skipped → fewer rect/text cmds in the window's
// draw list). Tests use the existing `beginFrameRaw` host
// harness, same model as the editStruct tests.

test "P5.1: WindowFlags defaults are all false (legacy compatible)" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.is_child);
    try expectEqual(false, f.no_title_bar);
    try expectEqual(false, f.no_resize);
    try expectEqual(false, f.no_move);
    try expectEqual(false, f.no_collapse);
    try expectEqual(false, f.no_background);
    try expectEqual(false, f.no_scrollbar);
}

test "P5.1: flags propagate from WindowOpts to Window.flags" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("flagged", .{
        .flags = .{
            .no_title_bar = true,
            .no_resize = true,
            .no_move = true,
            .no_background = true,
            .no_scrollbar = true,
        },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expect(w.flags.no_title_bar);
    try expect(w.flags.no_resize);
    try expect(w.flags.no_move);
    try expect(w.flags.no_background);
    try expect(w.flags.no_scrollbar);
    // Unset flag remains false.
    try expect(!w.flags.no_collapse);
}

test "P5.1: no_title_bar shifts content origin up by title_bar_height" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // First submission: WITHOUT no_title_bar (sanity check).
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("normal", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 300, 200 },
        }) orelse unreachable;
        const w: *Window = ctx.current_window.?;
        // origin.y = pos.y + title_bar_height + padding.y
        const expected_y: f32 = 100 + ctx.style.title_bar_height + ctx.style.window_padding[1];
        try expectApproxEqAbs(expected_y, w.layout.origin[1], 0.01);
        h.close();
    }

    // Second submission: WITH no_title_bar — same window, flag flips.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("normal", .{
            .flags = .{ .no_title_bar = true },
        }) orelse unreachable;
        const w: *Window = ctx.current_window.?;
        // origin.y = pos.y + 0 + padding.y (title bar absent)
        const expected_y: f32 = 100 + 0 + ctx.style.window_padding[1];
        try expectApproxEqAbs(expected_y, w.layout.origin[1], 0.01);
        h.close();
    }
}

test "P5.1: no_background skips the window-bg drawRectFilled" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Plain window — count cmds in its draw list after chrome render.
    const ui1: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("a", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 150 },
        }) orelse unreachable;
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "a")).?;
    const cmds_with_bg: usize = w1.draw_list.cmds.items.len;

    // Reset frame, same window now with no_background.
    const ui2: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("a", .{
            .flags = .{ .no_background = true },
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "a")).?;
    const cmds_without_bg: usize = w2.draw_list.cmds.items.len;

    // The no_background submission has exactly ONE fewer cmd (the
    // suppressed window-bg drawRectFilled).
    try expectEqual(cmds_with_bg - 1, cmds_without_bg);
}

test "P5.1: no_title_bar skips both the title rect and the title text" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui1: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("b", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 150 },
        }) orelse unreachable;
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "b")).?;
    const cmds_with_title: usize = w1.draw_list.cmds.items.len;

    const ui2: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("b", .{
            .flags = .{ .no_title_bar = true },
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "b")).?;
    const cmds_without_title: usize = w2.draw_list.cmds.items.len;

    // Title-bar removal drops 2 cmds: the title rect + the title text.
    try expectEqual(cmds_with_title - 2, cmds_without_title);
}

test "P5.1: no_resize skips the corner grip glyph (3 rect cmds)" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui1: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("c", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 150 },
        }) orelse unreachable;
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "c")).?;
    const cmds_with_grip: usize = w1.draw_list.cmds.items.len;

    const ui2: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("c", .{
            .flags = .{ .no_resize = true },
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "c")).?;
    const cmds_without_grip: usize = w2.draw_list.cmds.items.len;

    // The grip is 3 small filled rects forming the corner pattern.
    try expectEqual(cmds_with_grip - 3, cmds_without_grip);
}

test "P5.1: flags re-stamp each frame so caller can toggle them" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: flag set.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("toggle", .{
            .flags = .{ .no_resize = true },
        }) orelse unreachable;
        try expect(ctx.current_window.?.flags.no_resize);
        h.close();
    }

    // Frame 2: flag cleared on same window. Re-stamp logic must
    // pick up the new value rather than retaining the prior frame's.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("toggle", .{
            .flags = .{ .no_resize = false },
        }) orelse unreachable;
        try expect(!ctx.current_window.?.flags.no_resize);
        h.close();
    }
}

// ============================================================================
// — Sizing flags
// ============================================================================

test "P5.2: WindowFlags defaults include new sizing fields as false" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.always_auto_resize);
    try expectEqual(false, f.always_use_window_padding);
    try expectEqual(false, f.no_saved_settings);
}

test "P5.2: always_auto_resize ignores user_resized + matches content exactly" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: explicit huge initial_size + always_auto_resize.
    // Window should shrink to fit (empty content → tiny).
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("auto", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 500, 400 }, // user_resized = true at creation
            .flags = .{ .always_auto_resize = true },
        }) orelse unreachable;
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "auto")).?;
    // Empty content fits in roughly title_bar + 2× padding.
    // The window should be MUCH smaller than the 500×400 initial.
    try expect(w.size[1] < 100);

    // Frame 2: add some text, window should grow to fit.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("auto", .{
            .flags = .{ .always_auto_resize = true },
        }) orelse unreachable;
        ui.text("hi", .{});
        ui.text("there", .{});
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "auto")).?;
    // With content, the window grows. Anchor is "bigger than the
    // empty frame but smaller than the initial 400 we forced".
    try expect(w2.size[1] > 30);
    try expect(w2.size[1] < 200);
}

test "P5.2: no_saved_settings excludes the window from persistence" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = UiContext.init(gpa);
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    // Window A: normal → should appear in persistence.
    {
        const h: WindowHandle = u.window("persist_me", .{}) orelse unreachable;
        h.close();
    }
    // Window B: no_saved_settings → should NOT appear.
    {
        const h: WindowHandle = u.window("ephemeral", .{
            .flags = .{ .no_saved_settings = true },
        }) orelse unreachable;
        h.close();
    }

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    try expect(std.mem.indexOf(u8, bytes, "persist_me") != null);
    try expect(std.mem.indexOf(u8, bytes, "ephemeral") == null);
}

test "P5.2: always_auto_resize ignores persisted size on first frame" {
    // Simulate the persistence handoff: a persisted window with a
    // saved size, then the demo opts into always_auto_resize. The
    // saved size should be ignored — flag wins.
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = UiContext.init(gpa);
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Inject a persisted entry directly (simulating a payload that
    // was loaded by tryAutoLoad before the first beginFrame).
    try ctx.pending_persistence.put(gpa, try gpa.dupe(u8, "saved"), .{
        .name = "saved",
        .pos = .{ 100, 100 },
        .size = .{ 400, 300 },
        .scroll_y = 0,
        .user_resized = true,
        .tab_bars = &.{},
    });

    const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = u.window("saved", .{
        .initial_size = .{ 100, 50 }, // would otherwise lose to persisted
        .flags = .{ .always_auto_resize = true },
    }) orelse unreachable;
    h.close();

    const w: *Window = ctx.windows.get(hashStr(0, "saved")).?;
    // Persisted 400×300 was IGNORED; auto_resize then shrunk to
    // content (which is empty), so the size is tiny — definitely
    // not 400×300.
    try expect(w.size[0] < 200);
    try expect(w.size[1] < 100);
}

// ============================================================================
// — Focus + ordering flags
// ============================================================================
// All four flags are reserved slots today (no z-order sort + no nav
// state machine). Tests verify the API surface: defaults are false,
// flags propagate from opts to Window.flags. Behavioral tests will
// land when the underlying mechanisms do.

test "P5.3: WindowFlags defaults include focus/nav fields as false" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.no_focus_on_appearing);
    try expectEqual(false, f.no_bring_to_front_on_focus);
    try expectEqual(false, f.no_nav_focus);
    try expectEqual(false, f.no_nav_inputs);
}

test "P5.3: focus/nav flags propagate from WindowOpts to Window.flags" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("nav_test", .{
        .flags = .{
            .no_focus_on_appearing = true,
            .no_bring_to_front_on_focus = true,
            .no_nav_focus = true,
            .no_nav_inputs = true,
        },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expect(w.flags.no_focus_on_appearing);
    try expect(w.flags.no_bring_to_front_on_focus);
    try expect(w.flags.no_nav_focus);
    try expect(w.flags.no_nav_inputs);
}

// ============================================================================
// — Input passthrough flags
// ============================================================================

test "P5.4: WindowFlags defaults include passthrough fields as false" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.no_mouse_inputs);
    try expectEqual(false, f.no_inputs);
}

test "P5.4: passthrough flags propagate from WindowOpts to Window.flags" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("passthrough", .{
        .flags = .{
            .no_mouse_inputs = true,
            .no_inputs = true,
        },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expect(w.flags.no_mouse_inputs);
    try expect(w.flags.no_inputs);
}

test "P5.4: no_mouse_inputs lets hover fall through to deeper window" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame with mouse positioned at (150, 150) — inside both windows.
    // Submission order: "bottom" first, then "top" (overlay).
    // Without the flag, "top" would claim hover. With it, "bottom"
    // keeps the hover.
    const snap: InputSnapshot = .{ .mouse_pos = .{ 150, 150 } };
    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    {
        const h: WindowHandle = ui.window("bottom", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 300, 200 },
        }) orelse unreachable;
        h.close();
    }
    // Sanity: "bottom" claimed hover on its own submission.
    const bottom_id: Id = hashStr(0, "bottom");
    try expectEqual(bottom_id, ctx.hovered_window_id);

    {
        const h: WindowHandle = ui.window("top", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 300, 200 },
            .flags = .{ .no_mouse_inputs = true },
        }) orelse unreachable;
        h.close();
    }

    // "top" was submitted later (covers "bottom"), but the flag
    // suppressed its hover claim — "bottom" still owns the hover.
    try expectEqual(bottom_id, ctx.hovered_window_id);
}

test "P5.4: no_inputs has the same passthrough effect as no_mouse_inputs" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const snap: InputSnapshot = .{ .mouse_pos = .{ 150, 150 } };
    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);

    {
        const h: WindowHandle = ui.window("bottom_b", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 300, 200 },
        }) orelse unreachable;
        h.close();
    }
    {
        const h: WindowHandle = ui.window("top_b", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 300, 200 },
            .flags = .{ .no_inputs = true }, // superset flag
        }) orelse unreachable;
        h.close();
    }

    const bottom_id: Id = hashStr(0, "bottom_b");
    try expectEqual(bottom_id, ctx.hovered_window_id);
}

test "P5.4: passthrough window still RENDERS (only input is blocked)" {
    // Sanity: visual chrome must be unaffected by the input flags —
    // the flagged window's draw cmds (bg, title, grip) should match
    // a non-flagged baseline. No regression of chrome rendering.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Baseline: plain window.
    const ui1: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("vis", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 150 },
        }) orelse unreachable;
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "vis")).?;
    const baseline_cmds: usize = w1.draw_list.cmds.items.len;

    // Same window, now with no_mouse_inputs.
    const ui2: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("vis", .{
            .flags = .{ .no_mouse_inputs = true },
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "vis")).?;
    const passthrough_cmds: usize = w2.draw_list.cmds.items.len;

    // Identical cmd count — chrome rendering is independent of input.
    try expectEqual(baseline_cmds, passthrough_cmds);
}

// ============================================================================
// — menu_bar + unsaved_document
// ============================================================================

test "P5.5a: WindowFlags defaults include menu_bar + unsaved_document as false" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.menu_bar);
    try expectEqual(false, f.unsaved_document);
}

test "P5.5a: menu_bar + unsaved_document flags propagate from opts" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("doc", .{
        .flags = .{ .menu_bar = true, .unsaved_document = true },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expect(w.flags.menu_bar);
    try expect(w.flags.unsaved_document);
}

test "P5.5a: beginMenuBar returns false without the menu_bar flag" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    if (ui.window("no_flag", .{
        .initial_pos = .{ 50, 50 },
        .initial_size = .{ 300, 200 },
        // no menu_bar flag set
    })) |w| {
        defer w.close();
        // Without the flag, beginMenuBar must return false and NOT
        // open the bar (matches imgui's ImGuiWindowFlags_MenuBar
        // contract).
        try expect(!ui.beginMenuBar());
        try expect(!ctx.in_menu_bar);
    }
}

test "P5.5a: beginMenuBar returns true WITH the menu_bar flag" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    if (ui.window("with_flag", .{
        .initial_pos = .{ 50, 50 },
        .initial_size = .{ 300, 200 },
        .flags = .{ .menu_bar = true },
    })) |w| {
        defer w.close();
        try expect(ui.beginMenuBar());
        try expect(ctx.in_menu_bar);
        ui.endMenuBar();
    }
}

test "P5.5a: unsaved_document marker draws — same chrome cmd count, different title text" {
    // The marker is "{name} *" formatted into a stack buffer, then
    // drawn as ONE text cmd at the same position the plain title
    // would have used. Net effect: same number of chrome cmds as
    // a plain window (the title is still one cmd), but the text
    // payload differs. Test verifies cmd count parity AND that
    // the marker is observable in the formatted output by
    // re-running the format check at the call site.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Baseline.
    const ui1: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("untitled", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 150 },
        }) orelse unreachable;
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "untitled")).?;
    const baseline_cmds: usize = w1.draw_list.cmds.items.len;

    // With unsaved_document.
    const ui2: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("untitled", .{
            .flags = .{ .unsaved_document = true },
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "untitled")).?;
    const marker_cmds: usize = w2.draw_list.cmds.items.len;

    // Same number of chrome cmds — marker just changes the title's
    // string payload, not the cmd structure.
    try expectEqual(baseline_cmds, marker_cmds);

    // Sanity-check the format produces the expected " *" suffix.
    // (The actual title cmd is buried in the draw list; this is a
    // contract test on the format step itself.)
    var name_buf: [132]u8 = undefined;
    const formatted: []const u8 = try bufPrint(&name_buf, "{s} *", .{w2.name()});
    try expectEqualStrings("untitled *", formatted);
}

// ============================================================================
// — Horizontal scrollbar + accessors
// ============================================================================

test "P5.5b: WindowFlags defaults include horizontal_scrollbar as false" {
    const f: WindowFlags = .{};
    try expectEqual(false, f.horizontal_scrollbar);
}

test "P5.5b: horizontal_scrollbar flag propagates from WindowOpts" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("scrollx", .{
        .flags = .{ .horizontal_scrollbar = true },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expect(w.flags.horizontal_scrollbar);
}

test "P5.5b: scroll_max_x stays 0 without the flag even on X overflow" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Force user_resized so closeWindow doesn't auto-grow the window
    // to fit content. We need real X overflow to test the flag gate.
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("plain", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 100, 100 }, // small window
        }) orelse unreachable;
        // Submit a wide widget — should overflow X.
        ui.text("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", .{});
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "plain")).?;
    // No flag → scroll_max_x clamped to 0 regardless of overflow.
    try expectEqual(@as(f32, 0), w.scroll_max_x);
    try expectEqual(@as(f32, 0), w.scroll_x);
}

test "P5.5b: scroll_max_x > 0 when flag set + content overflows" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Pre-mark the window's cursor_max.x past the right edge to
    // simulate a wide widget. Cleaner than relying on font metrics
    // (which can vary by 1-2px between platforms).
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("hscroll", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        // Push cursor_max far to the right so content_w_natural is
        // clearly > viewport_w.
        const w_mid: *Window = ctx.current_window.?;
        w_mid.layout.cursor_max[0] = w_mid.pos[0] + 500; // 500px content
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "hscroll")).?;
    try expect(w.scroll_max_x > 0);
}

test "P5.5b: wheel-X scrolls window with flag set, ignored without" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: with horizontal_scrollbar set + wheel-X delta + mouse
    // inside the window → scroll_x should advance.
    const snap1: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_wheel_x = 2.0,
    };
    const ui1: Ui = ctx.beginFrameRaw(snap1, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui1.window("a", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        const w_mid: *Window = ctx.current_window.?;
        w_mid.layout.cursor_max[0] = w_mid.pos[0] + 500; // ensure max_x > 0
        h.close();
    }
    const w1: *Window = ctx.windows.get(hashStr(0, "a")).?;
    try expect(w1.scroll_x > 0);
    try expect(ctx.mouse_wheel_x_consumed);

    // Frame 2: same shape, NO flag. scroll_x must stay 0
    // (wheel_x_consumed also stays false — no window claimed it).
    const snap2: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_wheel_x = 2.0,
    };
    const ui2: Ui = ctx.beginFrameRaw(snap2, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui2.window("b", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            // no flag
        }) orelse unreachable;
        h.close();
    }
    const w2: *Window = ctx.windows.get(hashStr(0, "b")).?;
    try expectEqual(@as(f32, 0), w2.scroll_x);
    try expectEqual(false, ctx.mouse_wheel_x_consumed);
}

test "P5.5b: setScrollX / getScrollX round-trip with clamping" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("s", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        // Force scroll_max_x = 300 by mutating directly (closeWindow
        // hasn't run yet so cursor_max would otherwise be tiny).
        ctx.current_window.?.scroll_max_x = 300;

        // Normal set in range.
        ui.setScrollX(150);
        try expectEqual(@as(f32, 150), ui.getScrollX());

        // Over-range clamps to scroll_max_x.
        ui.setScrollX(99999);
        try expectEqual(@as(f32, 300), ui.getScrollX());

        // Negative clamps to 0.
        ui.setScrollX(-50);
        try expectEqual(@as(f32, 0), ui.getScrollX());

        // getScrollMaxX returns the stored max.
        try expectEqual(@as(f32, 300), ui.getScrollMaxX());
        h.close();
    }
}

test "P5.5b: X + Y wheel consumption are independent" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Both axes have nonzero deltas + hovered window opts into both
    // (horizontal_scrollbar set, no_scrollbar absent). Both should
    // consume independently — neither blocks the other.
    const snap: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_wheel_x = 1.0,
        .mouse_wheel_y = 1.0,
    };
    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("both", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        const w_mid: *Window = ctx.current_window.?;
        w_mid.layout.cursor_max[0] = w_mid.pos[0] + 500;
        w_mid.layout.cursor_max[1] = w_mid.pos[1] + 500;
        h.close();
    }
    try expect(ctx.mouse_wheel_consumed); // Y consumed
    try expect(ctx.mouse_wheel_x_consumed); // X also consumed
}

// ============================================================================
// P8.5 — shift-wheel pan + scroll_x persistence
// ============================================================================

test "P8.5: shift+wheel-y routes to scroll_x when horizontal_scrollbar set" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    var snap: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_wheel_y = -2.0,
    };
    snap.keys[@intFromEnum(KeyCode.left_shift)] = .{
        .down = true,
        .pressed_this_frame = true,
        .released_this_frame = false,
        .down_duration_frames = 1,
    };

    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("pan", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        ctx.current_window.?.layout.cursor_max[0] = ctx.current_window.?.pos[0] + 500;
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "pan")).?;
    // wheel_y = -2.0 + shift held → scroll_x advances right
    // (mirrors the wheel-y → scroll-y sign convention: scroll_x -=
    // wheel_y * speed).  scroll_x = 0 initially, so a negative
    // wheel_y produces a positive scroll_x.
    try expect(w.scroll_x > 0);
    // scroll_y must NOT advance — shift-pan consumed the wheel
    // before the normal Y-wheel handler could fire.
    try expectEqual(@as(f32, 0), w.scroll_y);
    // Both consumption flags get set so neither outer scope
    // double-applies.
    try expect(ctx.mouse_wheel_x_consumed);
    try expect(ctx.mouse_wheel_consumed);
}

test "P8.5: shift+wheel-y falls through to scroll_y when no h-scrollbar" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Shift held but the window has NO horizontal_scrollbar.  Shift
    // pan must not fire (no dead key); wheel-y goes to scroll_y.
    var snap: InputSnapshot = .{
        .mouse_pos = .{ 100, 100 },
        .mouse_wheel_y = -2.0,
    };
    snap.keys[@intFromEnum(KeyCode.left_shift)] = .{
        .down = true,
        .pressed_this_frame = true,
        .released_this_frame = false,
        .down_duration_frames = 1,
    };

    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("noh", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            // no horizontal_scrollbar flag
        }) orelse unreachable;
        ctx.current_window.?.layout.cursor_max[1] = ctx.current_window.?.pos[1] + 500;
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "noh")).?;
    // scroll_y advanced because shift-pan didn't fire (mirrors the
    // normal wheel-y → scroll-y sign convention).
    try expect(w.scroll_y > 0);
    try expectEqual(@as(f32, 0), w.scroll_x);
}

test "P8.5: shift+wheel-y also requires hovered_window_id match" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Mouse is OUTSIDE the window: hovered_window_id won't match.
    var snap: InputSnapshot = .{
        .mouse_pos = .{ 700, 500 },
        .mouse_wheel_y = 2.0,
    };
    snap.keys[@intFromEnum(KeyCode.left_shift)] = .{
        .down = true,
        .pressed_this_frame = true,
        .released_this_frame = false,
        .down_duration_frames = 1,
    };

    const ui: Ui = ctx.beginFrameRaw(snap, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    {
        const h: WindowHandle = ui.window("nope", .{
            .initial_pos = .{ 50, 50 },
            .initial_size = .{ 200, 100 },
            .flags = .{ .horizontal_scrollbar = true },
        }) orelse unreachable;
        ctx.current_window.?.layout.cursor_max[0] = ctx.current_window.?.pos[0] + 500;
        h.close();
    }
    const w: *Window = ctx.windows.get(hashStr(0, "nope")).?;
    try expectEqual(@as(f32, 0), w.scroll_x);
    try expectEqual(@as(f32, 0), w.scroll_y);
}

test "P8.5: PersistedWindow round-trips scroll_x value" {
    const gpa: Allocator = std.testing.allocator;

    // Build a PersistedWindow with scroll_x set, serialize to .zon
    // bytes via the schema's struct, then re-parse and confirm
    // the round-trip preserves scroll_x.
    const original: PersistedWindow = .{
        .name = "saved",
        .pos = .{ 100, 100 },
        .size = .{ 400, 300 },
        .scroll_y = 50,
        .scroll_x = 75,
        .user_resized = true,
        .tab_bars = &.{},
    };

    var alloc_writer: std.Io.Writer.Allocating = .init(gpa);
    defer alloc_writer.deinit();
    try std.zon.stringify.serialize(original, .{}, &alloc_writer.writer);
    const text: []const u8 = alloc_writer.written();

    const text_z: [:0]u8 = try dupeZ(gpa, text);
    defer gpa.free(text_z);
    const parsed: PersistedWindow = try std.zon.parse.fromSliceAlloc(
        PersistedWindow,
        gpa,
        text_z,
        null,
        .{},
    );
    defer std.zon.parse.free(gpa, parsed);

    try expectEqual(@as(f32, 75), parsed.scroll_x);
    try expectEqual(@as(f32, 50), parsed.scroll_y);
}

test "P8.5: legacy PersistedWindow without scroll_x parses with default 0" {
    const gpa: Allocator = std.testing.allocator;

    // Older .zon files written before scroll_x was added.  They
    // contain every other field but no scroll_x.  Parsing must
    // succeed and default scroll_x to 0.
    const legacy_zon: [:0]const u8 =
        \\.{
        \\    .name = "old",
        \\    .pos = .{ 10, 20 },
        \\    .size = .{ 200, 100 },
        \\    .scroll_y = 33,
        \\    .user_resized = true,
        \\    .tab_bars = .{},
        \\}
    ;

    const parsed: PersistedWindow = try std.zon.parse.fromSliceAlloc(
        PersistedWindow,
        gpa,
        legacy_zon,
        null,
        .{},
    );
    defer std.zon.parse.free(gpa, parsed);

    try expectEqual(@as(f32, 0), parsed.scroll_x);
    try expectEqual(@as(f32, 33), parsed.scroll_y);
}

test "P8.5: openWindow restores scroll_x from PersistedWindow on first frame" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Stash a pending entry with scroll_x = 42 before any window
    // submission.  openWindow must consume the entry and copy
    // scroll_x onto the new Window.
    const name_copy: []u8 = try ctx.gpa.dupe(u8, "restored");
    try ctx.pending_persistence.put(ctx.gpa, name_copy, .{
        .name = name_copy,
        .pos = .{ 30, 40 },
        .size = .{ 300, 200 },
        .scroll_y = 0,
        .scroll_x = 42,
        .user_resized = true,
        .tab_bars = &.{},
    });

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("restored", .{
        .flags = .{ .horizontal_scrollbar = true },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    try expectEqual(@as(f32, 42), w.scroll_x);
}

test "P8.5: scroll_x shifts cursor_pos[0] left so content draws at offset" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Restore a window with scroll_x = 30 already set.  After
    // openWindow runs, the first widget's effective draw X should
    // be inner_origin[0] - 30, NOT inner_origin[0].  Without the
    // shift, scroll_x is bookkeeping-only and the user-visible
    // content never moves when they drag the X scrollbar.
    const name_copy: []u8 = try ctx.gpa.dupe(u8, "shifty");
    try ctx.pending_persistence.put(ctx.gpa, name_copy, .{
        .name = name_copy,
        .pos = .{ 50, 50 },
        .size = .{ 300, 200 },
        .scroll_y = 0,
        .scroll_x = 30,
        .user_resized = true,
        .tab_bars = &.{},
    });

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("shifty", .{
        .flags = .{ .horizontal_scrollbar = true, .no_title_bar = true },
    }) orelse unreachable;
    defer h.close();

    const w: *Window = ctx.current_window.?;
    // Inner origin is `pos + window_padding` because no_title_bar
    // skips the title-bar height.  Cursor should be at
    // (inner_origin_x - scroll_x).
    const inner_origin_x: f32 = w.pos[0] + ctx.style.window_padding[0];
    try expectEqual(inner_origin_x - 30, w.layout.cursor_pos[0]);
}

test "P8.5: cursor_pos[0] stays shifted by scroll_x after newLine (every row)" {
    // The "only first widget shifts" bug: openWindow shifted the
    // cursor once, but advanceLayout reset cursor_pos[0] to
    // `origin[0] + indent_x` on every new line, dropping the
    // shift.  Mimics imgui's `window.Pos.x - window.Scroll.x +
    // local_x` formula: every line-reset includes the scroll
    // subtraction.
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    const name_copy: []u8 = try ctx.gpa.dupe(u8, "scrolled");
    try ctx.pending_persistence.put(ctx.gpa, name_copy, .{
        .name = name_copy,
        .pos = .{ 100, 100 },
        .size = .{ 400, 300 },
        .scroll_y = 0,
        .scroll_x = 25,
        .user_resized = true,
        .tab_bars = &.{},
    });

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("scrolled", .{
        .flags = .{ .horizontal_scrollbar = true, .no_title_bar = true },
    }) orelse unreachable;

    const w: *Window = ctx.current_window.?;
    const inner_origin_x: f32 = w.pos[0] + ctx.style.window_padding[0];
    const expected_x: f32 = inner_origin_x - 25;

    // Submit a couple of "rows" to force newLine/advanceLayout
    // to fire.  After each one, cursor_pos[0] must be back at
    // the scroll-aware origin, not the unshifted origin.
    ui.newLine();
    try expectEqual(expected_x, w.layout.cursor_pos[0]);

    ui.newLine();
    try expectEqual(expected_x, w.layout.cursor_pos[0]);

    ui.newLine();
    try expectEqual(expected_x, w.layout.cursor_pos[0]);

    h.close();
}

// ============================================================================
// tests - editArrayList over std.ArrayList of various types.
// Each test pins one architectural contract: compile-time dispatch on
// the list's element type, persistent open/closed state via the
// underlying tree node, and the per-element pushId scoping that
// prevents widget-id collisions across rows.
// ============================================================================

test " editArrayList compiles + iterates over an empty list" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var list: ArrayList(i32) = .empty;
    defer list.deinit(std.testing.allocator);
    // No clicks → no changes; returns false.
    try expect(!ui.editArrayList("nums", &list));
    try expectEqual(@as(usize, 0), list.items.len);
}

test " editArrayList renders one row per existing element" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var list: ArrayList(i32) = .empty;
    defer list.deinit(std.testing.allocator);
    try list.append(std.testing.allocator, 10);
    try list.append(std.testing.allocator, 20);
    try list.append(std.testing.allocator, 30);

    // Frame 1 - open the tree node by clicking it. The tree node
    // sits at the cursor position on first widget submission;
    // measure it via last_item_rect AFTER a no-op frame.
    var tree_rect: Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    {
        const wopts: WindowOpts = .{ .initial_pos = .{ 0, 0 }, .initial_size = .{ 400, 400 } };
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", wopts) orelse unreachable;
        _ = ui.editArrayList("nums", &list);
        // After editArrayList returns, last_item_rect is the LAST
        // widget drawn - the "x" Remove button on the last element.
        // Just verify that some rect was recorded (non-zero size).
        tree_rect = h.win.layout.last_item_rect;
        h.close();
    }
    // Without opening the tree node, last_item_rect refers to the
    // collapsed tree-node row itself.
    try expect(tree_rect.width > 0);
    try expect(tree_rect.height > 0);
    // List still has 3 elements (we didn't click Add or Remove).
    try expectEqual(@as(usize, 3), list.items.len);
}

test " editArrayList returns false with no input + preserves elements" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var list: ArrayList(f32) = .empty;
    defer list.deinit(std.testing.allocator);
    try list.append(std.testing.allocator, 1.5);
    try list.append(std.testing.allocator, 2.5);

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    const changed: bool = ui.editArrayList("vals", &list);
    try expect(!changed);
    try expectEqual(@as(usize, 2), list.items.len);
    try expectEqual(@as(f32, 1.5), list.items[0]);
    try expectEqual(@as(f32, 2.5), list.items[1]);
}

test " editArrayList works with a struct element type" {
    const Item = struct {
        x: f32 = 0,
        y: f32 = 0,
        active: bool = false,
    };
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var list: ArrayList(Item) = .empty;
    defer list.deinit(std.testing.allocator);
    try list.append(std.testing.allocator, .{ .x = 1, .y = 2, .active = true });

    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    // No input → no change, but compile-time dispatch must work over
    // the struct field types.
    const changed: bool = ui.editArrayList("items", &list);
    try expect(!changed);
    try expectEqual(@as(usize, 1), list.items.len);
    // Element survives unmodified.
    try expectEqual(@as(f32, 1), list.items[0].x);
    try expectEqual(@as(f32, 2), list.items[0].y);
    try expectEqual(true, list.items[0].active);
}

// ============================================================================
// tests - style editor + layout persistence (serialize/apply).
// Each test pins one architectural contract: round-trip preservation
// of window geometry, recognition of the magic+version header,
// rejection of corrupt or version-mismatched buffers, and that the
// Color / Vec2 dispatch routes to the right widgets.
// ============================================================================

test " editColorField routes Color via colorEdit + writes back" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var col: Color = .{ .r = 100, .g = 150, .b = 200, .a = 255 };
    // No clicks - colorEdit returns false; values unchanged.
    const changed: bool = editColorField(&ctx, "tint", &col);
    try expect(!changed);
    try expectEqual(@as(u8, 100), col.r);
    try expectEqual(@as(u8, 150), col.g);
    try expectEqual(@as(u8, 200), col.b);
    try expectEqual(@as(u8, 255), col.a);
}

test " editVector2Field renders two inputFloats" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    var v: Vec2 = .{ 4.5, 7.25 };
    const changed: bool = editVector2Field(&ctx, "padding", &v);
    try expect(!changed);
    try expectEqual(@as(f32, 4.5), v[0]);
    try expectEqual(@as(f32, 7.25), v[1]);
}

test " styleEditor compiles + walks every Style field without panic" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    // No input changes anything; just verify the comptime walk
    // succeeds for every Style field. If a new field is added with
    // an unsupported type, the walk renders a "<unsupported …>"
    // text line but doesn't crash.
    const changed: bool = ui.styleEditor();
    try expect(!changed);
}

test " serializeLayout + applyLayout round-trip preserves geometry" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Create + touch two windows so they live in ctx.windows.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h_a: WindowHandle = ui.window(
            "settings",
            .{ .initial_pos = .{ 50, 60 }, .initial_size = .{ 320, 240 } },
        ) orelse unreachable;
        h_a.close();
        const h_b: WindowHandle = ui.window(
            "inspector",
            .{ .initial_pos = .{ 400, 80 }, .initial_size = .{ 280, 320 } },
        ) orelse unreachable;
        h_b.close();
    }

    // Manually mutate one window's pos + scroll + user_resized so
    // we can verify the round-trip preserves non-default values.
    const settings_id: Id = hashStr(0, "settings");
    const inspector_id: Id = hashStr(0, "inspector");
    const settings_w: *Window = ctx.windows.get(settings_id) orelse unreachable;
    settings_w.pos = .{ 123, 456 };
    settings_w.size = .{ 333, 222 };
    settings_w.scroll_y = 99;
    settings_w.user_resized = true;

    // Serialize.
    const buf: []u8 = try serializeLayoutImpl(&ctx, std.testing.allocator);
    defer std.testing.allocator.free(buf);

    // Reset both windows to default-ish values so we can detect
    // that applyLayout actually wrote to them.
    settings_w.pos = .{ 0, 0 };
    settings_w.size = .{ 0, 0 };
    settings_w.scroll_y = 0;
    settings_w.user_resized = false;
    const inspector_w_pre: *Window = ctx.windows.get(inspector_id) orelse unreachable;
    inspector_w_pre.pos = .{ 0, 0 };
    inspector_w_pre.size = .{ 0, 0 };

    // Apply - both windows should restore.
    try applyLayoutImpl(&ctx, buf);

    const settings_w_post: *Window = ctx.windows.get(settings_id) orelse unreachable;
    try expectEqual(@as(f32, 123), settings_w_post.pos[0]);
    try expectEqual(@as(f32, 456), settings_w_post.pos[1]);
    try expectEqual(@as(f32, 333), settings_w_post.size[0]);
    try expectEqual(@as(f32, 222), settings_w_post.size[1]);
    try expectEqual(@as(f32, 99), settings_w_post.scroll_y);
    try expectEqual(true, settings_w_post.user_resized);

    // Inspector also restored to its post-creation geometry (50,80,280,320 etc.).
    const inspector_w_post: *Window = ctx.windows.get(inspector_id) orelse unreachable;
    try expect(inspector_w_post.size[0] > 0); // restored, not zero
}

test " applyLayout rejects malformed ZON" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    // Garbage bytes - not valid ZON syntax.
    const bad: []const u8 = "this is not zon }}}";
    try expectError(error.LayoutFormatMismatch, applyLayoutImpl(&ctx, bad));
}

test " applyLayout rejects buffer with wrong schema version" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    // Valid ZON shape, but version field is wrong.
    const wrong_ver: []const u8 =
        \\.{
        \\    .version = 999,
        \\    .windows = .{},
        \\}
    ;
    try expectError(error.LayoutFormatMismatch, applyLayoutImpl(&ctx, wrong_ver));
}

test " applyLayout accepts ZON with empty windows array" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var ver_buf: [64]u8 = undefined;
    const empty_zon: []const u8 = bufPrint(
        &ver_buf,
        ".{{ .version = {d}, .windows = .{{}} }}",
        .{layout_schema_version},
    ) catch unreachable;
    try applyLayoutImpl(&ctx, empty_zon);
    try expectEqual(@as(usize, 0), ctx.windows.count());
}

test " applyLayout creates stub windows for titles not yet known" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();

    // Hand-rolled ZON describing one window - exercises the
    // human-readable contract end-to-end. No window is in
    // ctx.windows yet; applyLayout should create a stub keyed by
    // hashStr(0, "settings") whose geometry matches the ZON.
    var src_buf: [256]u8 = undefined;
    const src: []const u8 = bufPrint(&src_buf,
        \\.{{
        \\    .version = {d},
        \\    .windows = .{{
        \\        .{{
        \\            .title = "settings",
        \\            .pos = .{{ 100, 200 }},
        \\            .size = .{{ 300, 400 }},
        \\            .scroll_y = 10,
        \\            .user_resized = true,
        \\        }},
        \\    }},
        \\}}
    , .{layout_schema_version}) catch unreachable;

    try expectEqual(@as(usize, 0), ctx.windows.count());
    try applyLayoutImpl(&ctx, src);
    try expectEqual(@as(usize, 1), ctx.windows.count());

    const id: Id = hashStr(0, "settings");
    const stub: *Window = ctx.windows.get(id) orelse unreachable;
    try expectEqual(@as(f32, 100), stub.pos[0]);
    try expectEqual(@as(f32, 200), stub.pos[1]);
    try expectEqual(@as(f32, 300), stub.size[0]);
    try expectEqual(@as(f32, 400), stub.size[1]);
    try expectEqual(@as(f32, 10), stub.scroll_y);
    try expectEqual(true, stub.user_resized);
    // Title was copied into name_buf so a follow-up serialize round-trips.
    try expectEqualStrings("settings", stub.name());
}

test " serializeLayout produces human-readable ZON with the title" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Submit a window so it lands in ctx.windows with a real title.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("settings", .{
            .initial_pos = .{ 50, 60 },
            .initial_size = .{ 320, 240 },
        }) orelse unreachable;
        h.close();
    }

    const buf: []u8 = try serializeLayoutImpl(&ctx, std.testing.allocator);
    defer std.testing.allocator.free(buf);

    // Verify the title appears verbatim in the output - that's the
    // human-readability contract we're claiming.
    try expect(std.mem.indexOf(u8, buf, "\"settings\"") != null);
    try expect(std.mem.indexOf(u8, buf, ".version") != null);
    try expect(std.mem.indexOf(u8, buf, ".windows") != null);
}

// ============================================================================
// tests - keyboard nav (Tab focus + Enter activate) + showDemoWindow.
// Each test pins one architectural contract: Tab advances nav_id
// through the previous frame's submitted items, Shift+Tab steps
// backward, Enter on the focused widget synthesizes a click,
// showDemoWindow walks every category without panic.
// ============================================================================

test " Tab advances nav_id through submitted items in submission order" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1 - submit two buttons, no Tab pressed. Both register
    // as navigable; nav_id stays at 0 (unfocused).
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
    }
    try expectEqual(@as(Id, 0), ctx.nav_id);
    try expect(ctx.frame_nav_items.len >= 2);
    const first_id: Id = ctx.frame_nav_items.items[0];
    const second_id: Id = ctx.frame_nav_items.items[1];

    // Frame 2 - Tab pressed. advanceNav runs; nav_id was 0 (not in
    // list) so it focuses the FIRST item.
    {
        const ui: Ui = ctx.beginFrameRaw(keyEventSnapshot(.tab), null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
    }
    try expectEqual(first_id, ctx.nav_id);

    // Frame 3 - Tab pressed again. Advances to the SECOND item.
    {
        const ui: Ui = ctx.beginFrameRaw(keyEventSnapshot(.tab), null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
    }
    try expectEqual(second_id, ctx.nav_id);
}

test " Tab wraps from last item back to first" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1 - submit two buttons.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
    }
    const first_id: Id = ctx.frame_nav_items.items[0];

    // Manually focus the LAST item, then Tab.
    ctx.nav_id = ctx.frame_nav_items.items[1];
    {
        const ui: Ui = ctx.beginFrameRaw(keyEventSnapshot(.tab), null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
    }
    try expectEqual(first_id, ctx.nav_id);
}

test " Shift+Tab steps backward through the list" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1 - submit three buttons.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
        _ = ui.button("C", .{});
    }
    const first_id: Id = ctx.frame_nav_items.items[0];
    const middle_id: Id = ctx.frame_nav_items.items[1];

    // Focus middle, then Shift+Tab → back to first.
    ctx.nav_id = middle_id;
    {
        var shift_tab: InputSnapshot = keyEventSnapshot(.tab);
        shift_tab.keys[@intFromEnum(KeyCode.left_shift)].down = true;
        const ui: Ui = ctx.beginFrameRaw(
            shift_tab,
            null,
            800,
            600,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("A", .{});
        _ = ui.button("B", .{});
        _ = ui.button("C", .{});
    }
    try expectEqual(first_id, ctx.nav_id);
}

test " Enter on focused button synthesizes a click" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1 - submit a button so we know its id.
    var first_id: Id = 0;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.button("Activate me", .{});
        first_id = ctx.frame_nav_items.items[0];
    }

    // Frame 2 - focus the button (set nav_id) + press Enter.
    // Button should return true even though the mouse is nowhere
    // near it.
    ctx.nav_id = first_id;
    var clicked: bool = false;
    {
        const ui: Ui = ctx.beginFrameRaw(
            keyEventSnapshot(.enter),
            null,
            800,
            600,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        clicked = ui.button("Activate me", .{});
    }
    try expect(clicked);
}

test " Enter on focused checkbox toggles the value" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var is_checked: bool = false;

    var cb_id: Id = 0;
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.checkbox("Toggle me", &is_checked);
        cb_id = ctx.frame_nav_items.items[0];
    }
    try expectEqual(false, is_checked);

    ctx.nav_id = cb_id;
    {
        const ui: Ui = ctx.beginFrameRaw(
            keyEventSnapshot(.enter),
            null,
            800,
            600,
            &gl_dummy,
            &shapes_dummy,
            &font_dummy,
        );
        const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
        defer h.close();
        _ = ui.checkbox("Toggle me", &is_checked);
    }
    try expectEqual(true, is_checked);
}

test " Enter on UNFOCUSED button does NOT activate" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // No nav_id set - Enter has no target.
    const ui: Ui = ctx.beginFrameRaw(keyEventSnapshot(.enter), null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    const h: WindowHandle = ui.window("host", .{}) orelse unreachable;
    defer h.close();
    const clicked: bool = ui.button("Not focused", .{});
    try expect(!clicked);
}

test " showDemoWindow submits without panic + p_open=false skips" {
    var ctx: UiContext = .{
        .gpa = std.testing.allocator,
        .frame_arena = FrameArena.init(std.testing.allocator, ui_frame_arena_ceiling, "ui"),
        .canvas_w = 800,
        .canvas_h = 600,
    };
    defer ctx.deinit();
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    var demo: DemoState = .{};

    // Submit with no open flag - full demo runs.
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        ui.showDemoWindow(&demo, null);
    }
    // The Dear ZIMR window should be in ctx.windows now.
    const demo_id: Id = hashStr(0, "Dear ZIMR - demo");
    try expect(ctx.windows.get(demo_id) != null);

    // Submit with p_open=false - should be a no-op (no NEW window
    // created beyond what's already there from frame 1).
    var open: bool = false;
    const before_count: usize = ctx.windows.count();
    {
        const ui: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        ui.showDemoWindow(&demo, &open);
    }
    try expectEqual(before_count, ctx.windows.count());
}

// ============================================================================
// Text measurement + drawing - Style-driven (Tier B).
// ============================================================================
// When `style.font == null` we route through the default bitmap path
// (text2d.draw + measure) - same path as Phases 1-4. When
// `style.font` is set, we route through `drawTextEx` / `measureTextEx`
// for proper TTF rendering with per-pixel size and `font_spacing`.
// The legacy hardcoded `FONT_SIZE = 10` / `ctx.style.font_size = 10`
// constants are gone - line height is now `style.font_size` (which
// defaults to 10 to keep existing visual layout unchanged).

// All widget code routes text through `measureTextS` / `drawTextAtS`
// above (which read Style.font / font_size). Legacy line-height
// constant kept for layouts that still use it as a magic number
// gradually transitioning to `style.font_size` everywhere.

// ============================================================================
// Rendering - rlgl immediate-mode triangle emission.
// ============================================================================
// Adapted from rlImGui's ImGuiRenderTriangles + EnableScissor path.
// The difference: rlImGui consumes a pre-built ImDrawList; we render
// each widget's geometry eagerly as the widget runs. No per-frame
// draw-list bookkeeping, no z-ordering - keeps it simple.

// ============================================================================
// Layout / cursor tests
// ============================================================================

test "advanceLayout: solo widget sets cursor_max" {
    var w = Window{
        .id = 1,
        .pos = .{ 100, 100 },
        .size = .{ 200, 200 },
        .layout = .{
            .origin = .{ 100, 100 },
            .cursor_pos = .{ 100, 100 },
            .cursor_max = .{ 100, 100 },
        },
    };
    advanceLayout(&w, .{ 50, 20 }, .{ 8, 4 }, 8);
    try expectEqual(@as(f32, 150), w.layout.cursor_max[0]);
    try expectEqual(@as(f32, 120), w.layout.cursor_max[1]);
    // Next-row cursor should be below the placed widget.
    try expect(w.layout.cursor_pos[1] > 100);
}

test "advanceLayout: records cursor_pos_prev_line for sameLine" {
    // After a widget advances, the layout scope must remember the
    // row's right edge + top so the next sameLine can rejoin it.
    // This is the structural fix for the turn-308 sameLine bug.
    var w = Window{
        .id = 1,
        .pos = .{ 0, 0 },
        .size = .{ 400, 400 },
        .layout = .{
            .origin = .{ 8, 8 },
            .cursor_pos = .{ 8, 8 },
            .cursor_max = .{ 8, 8 },
        },
    };
    advanceLayout(&w, .{ 50, 20 }, .{ 4, 4 }, 8);
    // Right edge of placed widget = 8 + 50 = 58.
    try expectEqual(@as(f32, 58), w.layout.cursor_pos_prev_line[0]);
    // Top of row = where the widget was placed.
    try expectEqual(@as(f32, 8), w.layout.cursor_pos_prev_line[1]);
    // Row's tallest widget tracked.
    try expectEqual(@as(f32, 20), w.layout.prev_line_height);
}

// ============================================================================
// widget tests - pure functions (math + format) for now,
// since end-to-end widget invocation requires a UiContext + GL.
// ============================================================================

test "scalarToF64 / scalarFromF64: float roundtrip" {
    try expectApproxEqAbs(@as(f64, 0.5), scalarToF64(f32, 0.5), 1e-6);
    try expectApproxEqAbs(@as(f32, 0.25), scalarFromF64(f32, 0.25), 1e-6);
}

test "scalarToF64 / scalarFromF64: int rounds correctly" {
    try expectEqual(@as(f64, 42), scalarToF64(i32, 42));
    try expectEqual(@as(i32, 42), scalarFromF64(i32, 42.4));
    try expectEqual(@as(i32, 43), scalarFromF64(i32, 42.6));
    // Negative
    try expectEqual(@as(i32, -3), scalarFromF64(i32, -3.4));
}

test "scalarFromF64: extreme int range" {
    try expectEqual(@as(u8, 255), scalarFromF64(u8, 255.0));
    try expectEqual(@as(u8, 0), scalarFromF64(u8, 0.0));
    try expectEqual(@as(i64, 1_000_000), scalarFromF64(i64, 1_000_000.0));
}

test "colorToU32: wire format matches ImDrawVert.col layout" {
    // 0xAABBGGRR (little-endian) - same as ImGui's IM_COL32_*.
    const c = Color{ .r = 0x11, .g = 0x22, .b = 0x33, .a = 0x44 };
    const packed_v: ColorU32 = Color.toWire(c);
    try expectEqual(@as(u32, 0x44332211), packed_v);
}

test "formatScalar: float and int use type-appropriate defaults" {
    var buf: [32]u8 = undefined;
    // Float → 3 decimal places.
    const f_str = formatScalar(&buf, f32, 1.5, "");
    try expectEqualStrings("1.500", f_str);

    // Int → no decimals.
    const i_str = formatScalar(&buf, i32, -42, "");
    try expectEqualStrings("-42", i_str);
}

test "TooltipData: text() returns valid slice into buffer" {
    var t = TooltipData{ .pos = .{ 0, 0 } };
    const msg: []const u8 = "hello";
    @memcpy(t.text_buf[0..msg.len], msg);
    t.text_len = msg.len;
    try expectEqualStrings("hello", t.text());
}

// ============================================================================
// widget tests - pure functions / state-machine sanity.
// ============================================================================

test "drag clamping: in-range value untouched" {
    // Simulate a drag whose computed new value is inside [min, max].
    // Just exercising zm.clamp directly to lock in semantics
    // (the real drag loop tests would need a UiContext + GL stub).
    const new_f: f64 = 0.7;
    try expectEqual(@as(f64, 0.7), clamp(new_f, 0.0, 1.0));
}

test "drag clamping: out-of-range hits min/max" {
    try expectEqual(@as(f64, 1.0), clamp(@as(f64, 1.5), 0.0, 1.0));
    try expectEqual(@as(f64, 0.0), clamp(@as(f64, -0.3), 0.0, 1.0));
}

test "radioButton ID disambiguation: same label, different value → different IDs" {
    // A single radio group has multiple buttons sharing a label slot
    // (visible labels differ, but the ID derivation must include the
    // value so each button has its own active-id state).
    const win_id: Id = 0xDEADBEEF;
    const label_hash: Id = hashStr(win_id, "Option");
    const id_0: Id = hashInt(label_hash, 0);
    const id_1: Id = hashInt(label_hash, 1);
    const id_2: Id = hashInt(label_hash, 2);
    try expect(id_0 != id_1);
    try expect(id_1 != id_2);
    try expect(id_0 != id_2);
}

// ============================================================================
// Phase A2 clipper unit tests
// ============================================================================

/// Build a minimal UiContext + Window for clipper unit tests.
/// The window's `cursor_pos` is set so we know the clipper's
/// computed origin_y; viewport is the window's interior minus
/// title bar and padding. Caller must `deinit` the context.
/// also seeds `layout.origin` and `layout.work_rect_max`
/// because the clipper now reads its viewport bounds from these
/// (not from `w.pos + style padding`). Mirrors what `openWindow`
/// would have done - content area = window rect minus title bar
/// + window_padding. Using zimr's default style values (title 22,
/// padding 8) so the math matches a real workspace.
fn testMakeUiCtxWithWindow(
    gpa: Allocator,
    win_y: f32,
    win_h: f32,
    cursor_y: f32,
) !*UiContext {
    const ctx = try gpa.create(UiContext);
    ctx.* = UiContext.init(gpa);

    const title_bar: f32 = ctx.style.title_bar_height;
    const pad_y: f32 = ctx.style.window_padding[1];
    const inner_top: f32 = win_y + title_bar + pad_y;
    const inner_bot: f32 = win_y + win_h - pad_y;
    const pad_x: f32 = ctx.style.window_padding[0];

    const w = try gpa.create(Window);
    w.* = .{
        .id = 0xC11FA,
        .pos = .{ 0, win_y },
        .size = .{ 800, win_h },
        .layout = .{
            .origin = .{ pad_x, inner_top },
            .work_rect_max = .{ 800 - pad_x, inner_bot },
            .cursor_pos = .{ 0, cursor_y },
            .cursor_max = .{ 0, cursor_y },
        },
    };
    ctx.current_window = w;
    return ctx;
}

fn testDestroyUiCtxWithWindow(gpa: Allocator, ctx: *UiContext) void {
    if (ctx.current_window) |w| {
        gpa.destroy(w);
    }
    ctx.deinit();
    gpa.destroy(ctx);
}

test "Clipper.init: empty list completes immediately" {
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    var clip = Clipper.init(ctx, 0, 20);
    try expectEqual(Clipper.State.done, clip.state);
    try expectEqual(@as(?Clipper.Range, null), clip.step());
}

test "Clipper: visible range covers cursor-aligned rows" {
    // Window y=0, height=400, title_bar=20 + padding=8 → viewport
    // top = 28, bottom = 392. Cursor starts at 60 (so origin_y =
    // 60). Item height 20. Rows 0..N at y=60, 80, 100,...
    // First visible row: rows that intersect [28, 392]. Since
    // origin_y > vtop, first visible is row 0 (then overscan-1
    // clamps to 0 too). Visible rows = ceil((392-28)/20) + 1 + 1 + 1
    // = ceil(18.2) + 3 = 22.
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    var clip = Clipper.init(ctx, 1000, 20);
    const r1: Clipper.Range = clip.step() orelse return error.UnexpectedNullStep;
    try expect(r1.start == 0);
    try expect(r1.end > 0);
    try expect(r1.end < 1000); // virtualized, not all rows
    // Second step finalizes.
    try expectEqual(@as(?Clipper.Range, null), clip.step());
    // Third step is safe.
    try expectEqual(@as(?Clipper.Range, null), clip.step());
}

test "Clipper: cursor advances past full content height after step()" {
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    const item_count: usize = 100;
    const item_h: f32 = 20;
    var clip = Clipper.init(ctx, item_count, item_h);

    const r: Clipper.Range = clip.step() orelse return error.UnexpectedNullStep;
    // After the first step, cursor advanced to the first visible
    // row's y. Simulate the caller submitting `r.start..r.end` rows.
    const w: *Window = ctx.current_window.?;
    const rows_submitted: f32 = float(r.end - r.start);
    w.layout.cursor_pos[1] += rows_submitted * item_h;

    // End the clipper - cursor should land at origin + total*height.
    _ = clip.step();
    const expected_final_y: f32 = 60 + float(item_count) * item_h;
    try expectApproxEqAbs(expected_final_y, w.layout.cursor_pos[1], 0.5);
}

test "Clipper.end: completes iteration safely" {
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    var clip = Clipper.init(ctx, 1000, 20);
    clip.end();
    try expectEqual(Clipper.State.done, clip.state);
    // Idempotent - calling again is safe.
    clip.end();
    try expectEqual(Clipper.State.done, clip.state);
}

test "Clipper: tiny item count where every row visible" {
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    var clip = Clipper.init(ctx, 3, 20);
    const r: Clipper.Range = clip.step() orelse return error.UnexpectedNullStep;
    try expectEqual(@as(usize, 0), r.start);
    try expectEqual(@as(usize, 3), r.end); // capped at item_count
}

test "Clipper: zero or negative item_height clamped to 1" {
    const gpa: Allocator = std.testing.allocator;
    const ctx: *UiContext = try testMakeUiCtxWithWindow(gpa, 0, 400, 60);
    defer testDestroyUiCtxWithWindow(gpa, ctx);

    const clip = Clipper.init(ctx, 100, 0);
    try expectEqual(@as(f32, 1), clip.item_height);

    const clip2 = Clipper.init(ctx, 100, -5);
    try expectEqual(@as(f32, 1), clip2.item_height);
}

// ============================================================================
// Phase A3 multiline cursor helpers - unit tests
// ============================================================================
// These three helpers are pure (no UiContext mutation), so they
// test cleanly without the synthetic-Window fixture used by the
// clipper tests. `xyToByte` does take a UiContext for its text-
// measure call; we only test it indirectly via the line-walking
// part of `lineColToByte` here.

test "byteToLineCol: first line" {
    const buf: []const u8 = "hello\nworld\n!";
    const lc: LineCol = byteToLineCol(buf, 3);
    try expectEqual(@as(usize, 0), lc.line);
    try expectEqual(@as(usize, 3), lc.col);
}

test "byteToLineCol: at newline boundary" {
    const buf: []const u8 = "hello\nworld\n!";
    // Position 5 is the '\n' itself; still on line 0.
    const at_nl: LineCol = byteToLineCol(buf, 5);
    try expectEqual(@as(usize, 0), at_nl.line);
    try expectEqual(@as(usize, 5), at_nl.col);
    // Position 6 is the 'w' on line 1, col 0.
    const after_nl: LineCol = byteToLineCol(buf, 6);
    try expectEqual(@as(usize, 1), after_nl.line);
    try expectEqual(@as(usize, 0), after_nl.col);
}

test "byteToLineCol: middle line" {
    const buf: []const u8 = "hello\nworld\n!";
    // Position 9 is 'r' on line 1 (line starts at 6, col = 9 - 6 = 3).
    const lc: LineCol = byteToLineCol(buf, 9);
    try expectEqual(@as(usize, 1), lc.line);
    try expectEqual(@as(usize, 3), lc.col);
}

test "byteToLineCol: last line / end of buffer" {
    const buf: []const u8 = "hello\nworld\n!";
    // Last char 'l' on line 2, col 0.
    const lc: LineCol = byteToLineCol(buf, 12);
    try expectEqual(@as(usize, 2), lc.line);
    try expectEqual(@as(usize, 0), lc.col);
    // Past the end - col extends.
    const past: LineCol = byteToLineCol(buf, 13);
    try expectEqual(@as(usize, 2), past.line);
    try expectEqual(@as(usize, 1), past.col);
}

test "lineColToByte: round-trips byteToLineCol" {
    const buf: []const u8 = "abc\ndef\nghij";
    var pos: usize = 0;
    while (pos <= buf.len) : (pos += 1) {
        const lc: LineCol = byteToLineCol(buf, pos);
        const back = lineColToByte(buf, lc);
        try expectEqual(pos, back);
    }
}

test "lineColToByte: column past line-end clamps to line-end" {
    const buf: []const u8 = "ab\ncdef\ng";
    // Line 0 has 2 chars (a, b). Asking for col 99 should land
    // at line 0's end (byte 2 = position of the \n).
    const at_line0_end: usize = lineColToByte(buf, .{ .line = 0, .col = 99 });
    try expectEqual(@as(usize, 2), at_line0_end);
    // Line 2 has 1 char (g). Asking for col 5 → byte 9 (end of buf).
    const at_line2_end: usize = lineColToByte(buf, .{ .line = 2, .col = 5 });
    try expectEqual(@as(usize, 9), at_line2_end);
}

test "lineColToByte: line past last line clamps to buffer length" {
    const buf: []const u8 = "abc\ndef";
    // Line 99 doesn't exist - clamps to buf.len.
    const past: usize = lineColToByte(buf, .{ .line = 99, .col = 0 });
    try expectEqual(buf.len, past);
}

// ============================================================================
// Phase A4 callback-helper tests
// ============================================================================
// `InputTextCallbackData` is what callbacks see. Its helper
// methods (`insertChars`, `deleteChars`, `setBuffer`) handle
// buffer mutation + cursor + length accounting in one place; if
// they're wrong every callback that uses them is wrong.

fn makeCallbackData(
    buf: []u8,
    buf_len: *usize,
    cursor: *usize,
) InputTextCallbackData {
    return .{
        .event = .completion,
        .buf = buf,
        .buf_len = buf_len,
        .cursor_pos = cursor,
        .event_char = 0,
        .history_dir = .up,
        .user_data = null,
    };
}

test "InputTextCallbackData.insertChars: at cursor, pushes cursor right" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..5], "hello");
    var len: usize = 5;
    var cursor: usize = 5;
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    const ok: bool = data.insertChars(5, ", world");
    try expect(ok);
    try expectEqual(@as(usize, 12), len);
    try expectEqual(@as(usize, 12), cursor);
    try expectEqualSlices(u8, "hello, world", buf[0..len]);
}

test "InputTextCallbackData.insertChars: before cursor, cursor shifts right" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..5], "abcde");
    var len: usize = 5;
    var cursor: usize = 3; // sitting at 'd'
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    _ = data.insertChars(0, "XY");
    try expectEqual(@as(usize, 7), len);
    try expectEqual(@as(usize, 5), cursor);
    try expectEqualSlices(u8, "XYabcde", buf[0..len]);
}

test "InputTextCallbackData.insertChars: overflow returns false, leaves buf untouched" {
    var buf: [8]u8 = std.mem.zeroes([8]u8);
    @memcpy(buf[0..5], "hello");
    var len: usize = 5;
    var cursor: usize = 5;
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    const ok: bool = data.insertChars(5, "world!"); // 5 + 6 = 11 > 8
    try expect(!ok);
    try expectEqual(@as(usize, 5), len);
    try expectEqualSlices(u8, "hello", buf[0..len]);
}

test "InputTextCallbackData.deleteChars: after cursor, cursor unchanged" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..10], "helloworld");
    var len: usize = 10;
    var cursor: usize = 5; // sitting between hello / world
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    data.deleteChars(5, 5); // wipe "world"
    try expectEqual(@as(usize, 5), len);
    try expectEqual(@as(usize, 5), cursor);
    try expectEqualSlices(u8, "hello", buf[0..len]);
}

test "InputTextCallbackData.deleteChars: before cursor, cursor shifts left" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..10], "helloworld");
    var len: usize = 10;
    var cursor: usize = 10; // at end
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    data.deleteChars(0, 5); // wipe "hello"
    try expectEqual(@as(usize, 5), len);
    try expectEqual(@as(usize, 5), cursor);
    try expectEqualSlices(u8, "world", buf[0..len]);
}

test "InputTextCallbackData.deleteChars: overlapping cursor pulls cursor back" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..10], "helloworld");
    var len: usize = 10;
    var cursor: usize = 7; // 2 chars into "world"
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    data.deleteChars(5, 5); // wipe "world" - cursor was inside
    try expectEqual(@as(usize, 5), len);
    try expectEqual(@as(usize, 5), cursor);
}

test "InputTextCallbackData.setBuffer: replaces and moves cursor to end" {
    var buf: [16]u8 = std.mem.zeroes([16]u8);
    @memcpy(buf[0..5], "hello");
    var len: usize = 5;
    var cursor: usize = 2;
    var data: InputTextCallbackData = makeCallbackData(&buf, &len, &cursor);
    const ok: bool = data.setBuffer("greetings");
    try expect(ok);
    try expectEqual(@as(usize, 9), len);
    try expectEqual(@as(usize, 9), cursor);
    try expectEqualSlices(u8, "greetings", buf[0..len]);
}

// ============================================================================
// formatValue - unit tests
// ============================================================================
// `formatValue` is the pure dispatcher inside `valueImpl` - extracted so
// the comptime type-handling logic can be exercised without spinning up
// a full UiContext + Window fixture. One test per arm of the switch.

test "formatValue: bool true / false" {
    var buf: [16]u8 = undefined;
    try expectEqualSlices(u8, "true", formatValue(&buf, true));
    try expectEqualSlices(u8, "false", formatValue(&buf, false));
}

test "formatValue: signed int prints decimal" {
    var buf: [16]u8 = undefined;
    try expectEqualSlices(u8, "-42", formatValue(&buf, @as(i32, -42)));
    try expectEqualSlices(u8, "0", formatValue(&buf, @as(i32, 0)));
}

test "formatValue: unsigned int prints decimal" {
    var buf: [16]u8 = undefined;
    try expectEqualSlices(u8, "100", formatValue(&buf, @as(usize, 100)));
    try expectEqualSlices(u8, "65535", formatValue(&buf, @as(u32, 65535)));
}

test "formatValue: float prints with 3 decimals" {
    var buf: [16]u8 = undefined;
    try expectEqualSlices(u8, "1.234", formatValue(&buf, @as(f32, 1.234)));
    // Round-up edge: 0.0005 → "0.001" (single rounding step at 3 dp).
    try expectEqualSlices(u8, "0.000", formatValue(&buf, @as(f32, 0.0)));
}

test "formatValue: enum uses @tagName" {
    const Mode = enum { idle, running, paused };
    var buf: [16]u8 = undefined;
    try expectEqualSlices(u8, "running", formatValue(&buf, Mode.running));
    try expectEqualSlices(u8, "idle", formatValue(&buf, Mode.idle));
}

test "formatValue: string slice passthrough" {
    var buf: [16]u8 = undefined;
    const s: []const u8 = "hello";
    try expectEqualSlices(u8, "hello", formatValue(&buf, s));
}

test "formatValue: comptime int" {
    var buf: [16]u8 = undefined;
    // Literal `42` is a comptime_int - the dispatcher must accept that
    // tag, not just runtime ints.
    try expectEqualSlices(u8, "42", formatValue(&buf, 42));
}

test "formatValue: overflow returns empty slice (no crash)" {
    // 4-byte buffer; the number 12345 needs 5 bytes formatted ("12345").
    // bufPrint errors → caller's empty-slice fallback fires. Display
    // shows nothing rather than crashing the demo.
    var tiny: [4]u8 = undefined;
    const out = formatValue(&tiny, @as(i32, 12345));
    try expectEqual(@as(usize, 0), out.len);
}

// ============================================================================
// SelectionBasicStorage tests
// ============================================================================
// Headless tests for `SelectionBasicStorage`. No UiContext, no
// rendering - pure data-structure correctness. Imgui equivalents:
// `imgui_widgets.cpp:8580-8810` (default adapter case).
// The test fixtures synthesize `MultiSelectIO` and call
// `applyRequests` directly. This is the same code path that will
// run after `beginMultiSelect` / `endMultiSelect` once the
// click-emit logic lands.

test "SelectionBasicStorage: empty by default" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);
    try expectEqual(@as(usize, 0), sel.size());
    try expect(!sel.contains(0));
    try expect(!sel.contains(42));
}

test "SelectionBasicStorage: add and contains" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);
    try sel.add(std.testing.allocator, 5);
    try expect(sel.contains(5));
    try expect(!sel.contains(4));
    try expect(!sel.contains(6));
    try expectEqual(@as(usize, 1), sel.size());
}

test "SelectionBasicStorage: add same id is idempotent" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);
    try sel.add(std.testing.allocator, 7);
    try sel.add(std.testing.allocator, 7);
    try sel.add(std.testing.allocator, 7);
    try expectEqual(@as(usize, 1), sel.size());
}

test "SelectionBasicStorage: remove" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);
    try sel.add(std.testing.allocator, 3);
    try sel.add(std.testing.allocator, 4);
    sel.remove(3);
    try expect(!sel.contains(3));
    try expect(sel.contains(4));
    try expectEqual(@as(usize, 1), sel.size());
    // Removing a non-present id is a no-op.
    sel.remove(99);
    try expectEqual(@as(usize, 1), sel.size());
}

test "SelectionBasicStorage: clear" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);
    try sel.add(std.testing.allocator, 1);
    try sel.add(std.testing.allocator, 2);
    try sel.add(std.testing.allocator, 3);
    sel.clear();
    try expectEqual(@as(usize, 0), sel.size());
    try expect(!sel.contains(1));
    try expect(!sel.contains(2));
    try expect(!sel.contains(3));
}

test "SelectionBasicStorage.applyRequests: SetAll(true) selects all" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    const reqs = [_]SelectionRequest{.{ .set_all = true }};
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = null,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);

    try expectEqual(@as(usize, 10), sel.size());
    var i: u64 = 0;
    while (i < 10) : (i += 1) {
        try expect(sel.contains(i));
    }
}

test "SelectionBasicStorage.applyRequests: SetAll(false) clears" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    // Pre-populate.
    var i: u64 = 0;
    while (i < 5) : (i += 1) try sel.add(std.testing.allocator, i);

    const reqs = [_]SelectionRequest{.{ .set_all = false }};
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = null,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 5,
    };
    try sel.applyRequests(std.testing.allocator, &io);
    try expectEqual(@as(usize, 0), sel.size());
}

test "SelectionBasicStorage.applyRequests: SetRange inclusive both ends" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    const reqs = [_]SelectionRequest{.{ .set_range = .{
        .first = 2,
        .last = 5,
        .direction = 1,
        .selected = true,
    } }};
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = null,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);

    // [2, 3, 4, 5] inclusive → size 4.
    try expectEqual(@as(usize, 4), sel.size());
    try expect(!sel.contains(1));
    try expect(sel.contains(2));
    try expect(sel.contains(3));
    try expect(sel.contains(4));
    try expect(sel.contains(5));
    try expect(!sel.contains(6));
}

test "SelectionBasicStorage.applyRequests: SetRange unselect deletes" {
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    var i: u64 = 0;
    while (i < 10) : (i += 1) try sel.add(std.testing.allocator, i);

    const reqs = [_]SelectionRequest{.{ .set_range = .{
        .first = 3,
        .last = 7,
        .direction = 1,
        .selected = false,
    } }};
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = null,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);

    // Started 10, unselected 5 in [3..7] → 5 remain.
    try expectEqual(@as(usize, 5), sel.size());
    try expect(sel.contains(0));
    try expect(sel.contains(1));
    try expect(sel.contains(2));
    try expect(!sel.contains(3));
    try expect(!sel.contains(7));
    try expect(sel.contains(8));
}

test "SelectionBasicStorage.applyRequests: SetRange backward direction same result" {
    // direction is informational - applyRequests treats first/last
    // identically regardless of sign. Imgui keeps direction for
    // callers who want to preserve selection ORDER (which zimr
    // doesn't model today).
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    const reqs = [_]SelectionRequest{.{
        .set_range = .{
            .first = 5,
            .last = 2, // first > last is valid; means user shift-clicked upward
            .direction = -1,
            .selected = true,
        },
    }};
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = null,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);
    try expectEqual(@as(usize, 4), sel.size());
    try expect(sel.contains(2));
    try expect(sel.contains(5));
}

test "SelectionBasicStorage.applyRequests: typical shift-click pair (clear+range)" {
    // The most common request sequence: shift-click emits
    // SetAll(false) followed by SetRange(anchor..clicked, true).
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    // Caller had item 3 selected from a prior plain-click.
    try sel.add(std.testing.allocator, 3);

    const reqs = [_]SelectionRequest{
        .{ .set_all = false },
        .{ .set_range = .{ .first = 3, .last = 7, .direction = 1, .selected = true } },
    };
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = 3,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);

    try expectEqual(@as(usize, 5), sel.size());
    var i: u64 = 3;
    while (i <= 7) : (i += 1) try expect(sel.contains(i));
}

test "SelectionBasicStorage.applyRequests: Ctrl-click toggle pattern (no clear)" {
    // Ctrl-click emits ONLY SetRange(item..item, !was_selected)
    // no SetAll-clear preceding. Demonstrates that requests
    // compose correctly without auto-clear.
    var sel: SelectionBasicStorage = .{};
    defer sel.deinit(std.testing.allocator);

    try sel.add(std.testing.allocator, 1);
    try sel.add(std.testing.allocator, 4);

    // User Ctrl-clicked item 4: toggle to unselected.
    const reqs = [_]SelectionRequest{
        .{ .set_range = .{ .first = 4, .last = 4, .direction = 1, .selected = false } },
    };
    const io: MultiSelectIO = .{
        .requests = &reqs,
        .range_src_item = 4,
        .nav_id_item = null,
        .nav_id_selected = false,
        .items_count = 10,
    };
    try sel.applyRequests(std.testing.allocator, &io);

    try expect(sel.contains(1));
    try expect(!sel.contains(4));
    try expectEqual(@as(usize, 1), sel.size());
}

// ============================================================================
//: MultiSelect Begin/End + click-emit integration tests
// ============================================================================
// These tests construct a UiContext, drive a single
// beginMultiSelect → setNextItemSelectionUserData →
// multiSelectItemFooter → endMultiSelect cycle directly (bypassing
// the full beginFrame plumbing), and assert the resulting request
// list against the click → request truth table from the tutorial.
// What's exercised:
//   - Plain click: SetAll(false) + SetRange(item..item, true).
//   - Ctrl-click on unselected: SetRange(item..item, true) only.
//   - Ctrl-click on selected: SetRange(item..item, false) only
//     (toggle off, no auto-clear).
//   - Shift-click: SetAll(false) + SetRange(anchor..item, true).
//   - Escape (with clear_on_escape): SetAll(false) from Begin.
//   - clear_on_click_void: SetAll(false) when click hits no item.
//   - no_auto_clear_on_reselect: re-tap on selected leaves siblings.
// What's NOT exercised here (filed for follow-up tests):
//   - Multi-frame state survival (anchor across frames).
//   - Selectable's actual click path (these tests call the footer
//     directly with a synthesized `pressed=true`).

// Synthesize a focused-window state so beginMultiSelect's
// `is_focused = (focused_window_id == w.id)` succeeds without
// having to drive a real frame.
fn msTestSetup(ctx: *UiContext) *Window {
    const w = ctx.gpa.create(Window) catch unreachable;
    w.* = .{
        .id = 1234,
        .pos = .{ 0, 0 },
        .size = .{ 400, 800 },
    };
    ctx.windows.put(ctx.gpa, w.id, w) catch unreachable;
    ctx.current_window = w;
    ctx.focused_window_id = w.id;
    return w;
}

test "MultiSelect: plain click → SetAll(false) + SetRange(item..item, true)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);

    _ = beginMultiSelectImpl(&ctx, .{}, 0, 10);
    // Tag item 3, simulate a press on it.
    ctx.next_item_selection_user_data = 3;
    multiSelectItemFooter(&ctx, 999, false, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    // Expect exactly 2 requests: SetAll(false), SetRange(3..3, true).
    try expectEqual(@as(usize, 2), io.requests.len);
    try expect(io.requests[0] == .set_all);
    try expectEqual(false, io.requests[0].set_all);
    try expect(io.requests[1] == .set_range);
    try expectEqual(@as(u64, 3), io.requests[1].set_range.first);
    try expectEqual(@as(u64, 3), io.requests[1].set_range.last);
    try expectEqual(true, io.requests[1].set_range.selected);
}

test "MultiSelect: Ctrl-click on unselected → SetRange(item..item, true) only" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.keys[@intFromEnum(KeyCode.left_control)].down = true;

    _ = beginMultiSelectImpl(&ctx, .{}, 0, 10);
    ctx.next_item_selection_user_data = 7;
    multiSelectItemFooter(&ctx, 999, false, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    // Ctrl-click skips the SetAll-clear; just toggles single item.
    try expectEqual(@as(usize, 1), io.requests.len);
    try expect(io.requests[0] == .set_range);
    try expectEqual(@as(u64, 7), io.requests[0].set_range.first);
    try expectEqual(@as(u64, 7), io.requests[0].set_range.last);
    try expectEqual(true, io.requests[0].set_range.selected);
}

test "MultiSelect: Ctrl-click on selected → SetRange(item..item, false) only (toggle off)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.keys[@intFromEnum(KeyCode.left_control)].down = true;

    _ = beginMultiSelectImpl(&ctx, .{}, 1, 10);
    ctx.next_item_selection_user_data = 7;
    // Pass selected=true so the toggle resolves to false.
    multiSelectItemFooter(&ctx, 999, true, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    try expectEqual(@as(usize, 1), io.requests.len);
    try expect(io.requests[0] == .set_range);
    try expectEqual(false, io.requests[0].set_range.selected);
}

test "MultiSelect: Shift-click → SetAll(false) + SetRange(anchor..item, true)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);

    // First, plain-click item 3 to establish an anchor.
    _ = beginMultiSelectImpl(&ctx, .{}, 0, 10);
    ctx.next_item_selection_user_data = 3;
    multiSelectItemFooter(&ctx, 999, false, true);
    _ = endMultiSelectImpl(&ctx);
    // Sanity: anchor stored.
    const stored: MultiSelectState = ctx.ms_storage.get(ctx.current_multi_select.?.id) orelse unreachable;
    try expectEqual(@as(?u64, 3), stored.range_src_item);

    // Next frame: shift-click item 7.
    ctx.input.keys[@intFromEnum(KeyCode.left_shift)].down = true;
    _ = beginMultiSelectImpl(&ctx, .{}, 1, 10);
    ctx.next_item_selection_user_data = 7;
    multiSelectItemFooter(&ctx, 999, false, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    try expectEqual(@as(usize, 2), io.requests.len);
    try expect(io.requests[0] == .set_all);
    try expectEqual(false, io.requests[0].set_all);
    try expect(io.requests[1] == .set_range);
    try expectEqual(@as(u64, 3), io.requests[1].set_range.first);
    try expectEqual(@as(u64, 7), io.requests[1].set_range.last);
    try expectEqual(true, io.requests[1].set_range.selected);
    try expectEqual(@as(i2, 1), io.requests[1].set_range.direction);
}

test "MultiSelect: Escape with clear_on_escape → SetAll(false) from Begin" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    // selection_size > 0 is required to trigger the shortcut.
    const io: *const MultiSelectIO = beginMultiSelectImpl(&ctx, .{ .clear_on_escape = true }, 3, 10);

    // Pending request from Begin should be SetAll(false).
    try expectEqual(@as(usize, 1), io.requests.len);
    try expect(io.requests[0] == .set_all);
    try expectEqual(false, io.requests[0].set_all);

    // Also: the loop_request_set_all flag is set, so the Header
    // override fires for all items rendered in this frame.
    try expectEqual(
        @as(@TypeOf(ctx.current_multi_select.?.loop_request_set_all), .clear),
        ctx.current_multi_select.?.loop_request_set_all,
    );

    _ = endMultiSelectImpl(&ctx);
}

test "MultiSelect: Escape ignored when clear_on_escape is false" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    const io: *const MultiSelectIO = beginMultiSelectImpl(&ctx, .{}, 3, 10);
    try expectEqual(@as(usize, 0), io.requests.len);
    _ = endMultiSelectImpl(&ctx);
}

test "MultiSelect: Escape ignored when selection is empty" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.keys[@intFromEnum(KeyCode.escape)].pressed_this_frame = true;

    // selection_size == 0 → no clear shortcut (imgui parity).
    const io: *const MultiSelectIO = beginMultiSelectImpl(&ctx, .{ .clear_on_escape = true }, 0, 10);
    try expectEqual(@as(usize, 0), io.requests.len);
    _ = endMultiSelectImpl(&ctx);
}

test "MultiSelect: clear_on_click_void emits SetAll(false) on void click" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);
    ctx.input.mouse_left_clicked = true;
    // hovered_id stays 0 - nothing under the click → void.

    _ = beginMultiSelectImpl(&ctx, .{ .clear_on_click_void = true }, 1, 10);
    // No items submitted this frame.
    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    try expectEqual(@as(usize, 1), io.requests.len);
    try expect(io.requests[0] == .set_all);
    try expectEqual(false, io.requests[0].set_all);
}

test "MultiSelect: no_auto_clear_on_reselect - re-tap on selected skips SetAll-clear" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);

    _ = beginMultiSelectImpl(&ctx, .{ .no_auto_clear_on_reselect = true }, 1, 10);
    ctx.next_item_selection_user_data = 2;
    // Pass selected=true → re-tap on already-selected item.
    multiSelectItemFooter(&ctx, 999, true, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    // Expect ONLY the SetRange - no SetAll preceding.
    try expectEqual(@as(usize, 1), io.requests.len);
    try expect(io.requests[0] == .set_range);
    try expectEqual(@as(u64, 2), io.requests[0].set_range.first);
    try expectEqual(@as(u64, 2), io.requests[0].set_range.last);
    try expectEqual(true, io.requests[0].set_range.selected);
}

test "MultiSelect: no_range_select disables Shift behavior" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = msTestSetup(&ctx);

    // Anchor at 3.
    _ = beginMultiSelectImpl(&ctx, .{ .no_range_select = true }, 0, 10);
    ctx.next_item_selection_user_data = 3;
    multiSelectItemFooter(&ctx, 999, false, true);
    _ = endMultiSelectImpl(&ctx);

    // Shift+click at 7 - with no_range_select, behaves as plain click.
    ctx.input.keys[@intFromEnum(KeyCode.left_shift)].down = true;
    _ = beginMultiSelectImpl(&ctx, .{ .no_range_select = true }, 1, 10);
    ctx.next_item_selection_user_data = 7;
    multiSelectItemFooter(&ctx, 999, false, true);

    const io: *const MultiSelectIO = endMultiSelectImpl(&ctx);
    // Plain-click shape: SetAll(false) + SetRange(7..7, true).
    try expectEqual(@as(usize, 2), io.requests.len);
    try expect(io.requests[1] == .set_range);
    try expectEqual(@as(u64, 7), io.requests[1].set_range.first);
    try expectEqual(@as(u64, 7), io.requests[1].set_range.last);
}

// ============================================================================
//: Splitter tests
// ============================================================================
// Headless tests for splitterImpl. Constructs a UiContext + Window
// directly, then drives `splitterImpl` with synthesized input state
// across one or two frames and asserts the *split_pos behavior.
// What's exercised:
//   - Idle (no mouse interaction) → *split_pos unchanged.
//   - Held drag → *split_pos changes by mouse delta.
//   - Clamp against min1 (can't shrink first pane below it).
//   - Clamp against min2 (can't shrink second pane below it).
//   - Released (mouse_left_released) clears active_id.

fn splitterTestSetup(ctx: *UiContext) *Window {
    const w = ctx.gpa.create(Window) catch unreachable;
    w.* = .{
        .id = 5555,
        .pos = .{ 0, 0 },
        .size = .{ 400, 800 },
        // Cursor sits at window-padding (default 8,8 in style) - the
        // splitter widget computes geometry relative to cursor.
        .layout = .{
            .origin = .{ 8, 8 },
            .cursor_pos = .{ 8, 8 },
        },
    };
    ctx.windows.put(ctx.gpa, w.id, w) catch unreachable;
    ctx.current_window = w;
    return w;
}

test "Splitter: idle (no input) leaves *split_pos unchanged" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);

    var split_pos: f32 = 120;
    const held: bool = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
    });
    try expectEqual(false, held);
    try expectEqual(@as(f32, 120), split_pos);
}

test "Splitter: held drag mutates *split_pos by mouse delta (axis .x)" {
    // New semantics: bar renders AT the cursor, not at
    // cursor + split_pos. Caller positions the cursor at the bar's
    // intended X via sameLine after the first pane's endChild. So
    // we set the cursor to where the bar should sit (here: x=128,
    // simulating "pane 1 of width 120 ended; cursor now at x=128").
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    var split_pos: f32 = 120;

    // Cursor at (128, 8) → bar at x=128, w=4, center=130.
    w.layout.cursor_pos = .{ 128, 8 };
    ctx.input.mouse_pos = .{ 130, 100 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;

    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
    });
    // First-frame delta should be ~0 (mouse at bar center).
    try expectApproxEqAbs(@as(f32, 120), split_pos, 0.5);

    // Simulate next frame: cursor reset to new bar position
    // (pane 1's new width = updated split_pos).
    w.layout.cursor_pos = .{ 8 + split_pos, 8 };
    w.layout.line_height = 0;

    // Frame 2: mouse drifted right by +30. Bar wants to move with
    // it, so split_pos grows by 30.
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ 160, 100 };
    const held: bool = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
    });
    try expectEqual(true, held);
    // bar_center = cursor.x + 2 = 130. delta = 160 - 130 = 30.
    // split_pos = 120 + 30 = 150.
    try expectApproxEqAbs(@as(f32, 150), split_pos, 1.0);
}

test "Splitter: clamp against min1 (can't shrink first pane below)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    var split_pos: f32 = 100;

    // Cursor at where bar should sit (x = pane1.x + split_pos = 8+100=108).
    w.layout.cursor_pos = .{ 108, 8 };
    ctx.input.mouse_pos = .{ 110, 100 }; // bar center at 110
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
        .min1 = 80,
    });

    // Simulate next frame, cursor follows new split_pos.
    w.layout.cursor_pos = .{ 8 + split_pos, 8 };
    w.layout.line_height = 0;
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ 30, 100 }; // way to the left
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
        .min1 = 80,
    });

    // split_pos cannot go below min1=80.
    try expectApproxEqAbs(@as(f32, 80), split_pos, 0.5);
}

test "Splitter: clamp against min2 (can't shrink second pane below)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    var split_pos: f32 = 280; // close to right edge of total=400

    // Cursor at bar position = 8 + 280 = 288.
    w.layout.cursor_pos = .{ 288, 8 };
    ctx.input.mouse_pos = .{ 290, 100 }; // bar center
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
        .min2 = 100,
        .bar_width = 4,
    });

    w.layout.cursor_pos = .{ 8 + split_pos, 8 };
    w.layout.line_height = 0;
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ 380, 100 }; // way to the right
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
        .min2 = 100,
        .bar_width = 4,
    });

    // Max split_pos = total - bar_width - min2 = 400 - 4 - 100 = 296.
    try expectApproxEqAbs(@as(f32, 296), split_pos, 0.5);
}

test "Splitter: vertical (.y) axis drags along Y" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    var split_pos: f32 = 200;

    // Cursor at (8, 208) →.y bar at Y=208, h=4, center Y=210.
    w.layout.cursor_pos = .{ 8, 208 };
    ctx.input.mouse_pos = .{ 100, 210 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = splitterImpl(&ctx, "vs", &split_pos, .y, .{
        .total_along_axis = 600,
    });

    w.layout.cursor_pos = .{ 8, 8 + split_pos };
    w.layout.line_height = 0;
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ 100, 250 }; // drift +40 along Y
    const held: bool = splitterImpl(&ctx, "vs", &split_pos, .y, .{
        .total_along_axis = 600,
    });

    try expectEqual(true, held);
    try expectApproxEqAbs(@as(f32, 240), split_pos, 1.0);
}

test "Splitter: release clears active_id" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    var split_pos: f32 = 120;
    // Cursor at bar position 8+120=128.
    w.layout.cursor_pos = .{ 128, 8 };
    ctx.input.mouse_pos = .{ 130, 100 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
    });
    try expect(ctx.active_id != 0);

    w.layout.cursor_pos = .{ 8 + split_pos, 8 };
    w.layout.line_height = 0;
    // Release.
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_left_down = false;
    ctx.input.mouse_left_released = true;
    _ = splitterImpl(&ctx, "s", &split_pos, .x, .{
        .total_along_axis = 400,
    });
    try expectEqual(@as(Id, 0), ctx.active_id);
}

// ============================================================================
//: setNextWindowSizeConstraints + setWindow*-by-name tests
// ============================================================================

test "clampWindowSize: no bounds = identity" {
    try expectEqual(
        @as(Vec2, .{ 300, 400 }),
        clampWindowSize(.{ 300, 400 }, null, null),
    );
}

test "clampWindowSize: applies min on both axes" {
    try expectEqual(
        @as(Vec2, .{ 100, 200 }),
        clampWindowSize(.{ 50, 100 }, .{ 100, 200 }, null),
    );
}

test "clampWindowSize: applies max on both axes" {
    try expectEqual(
        @as(Vec2, .{ 500, 600 }),
        clampWindowSize(.{ 800, 900 }, null, .{ 500, 600 }),
    );
}

test "clampWindowSize: per-axis independent" {
    // X needs clamp, Y is fine.
    try expectEqual(
        @as(Vec2, .{ 100, 200 }),
        clampWindowSize(.{ 50, 200 }, .{ 100, 100 }, null),
    );
}

test "setWindow*-by-name: setWindowPos mutates the named window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    // Pre-populate the windows map with a Workspace window the
    // same way openWindow would have on first call.
    const w = ctx.gpa.create(Window) catch unreachable;
    const id: Id = hashStr(0, "Workspace");
    w.* = .{
        .id = id,
        .pos = .{ 0, 0 },
        .size = .{ 400, 800 },
    };
    ctx.windows.put(ctx.gpa, id, w) catch unreachable;

    const ui_ = Ui{ .ctx = &ctx };
    ui_.setWindowPos("Workspace", .{ 100, 50 });
    try expectEqual(@as(Vec2, .{ 100, 50 }), w.pos);
}

test "setWindow*-by-name: setWindowSize mutates + marks user_resized" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w = ctx.gpa.create(Window) catch unreachable;
    const id: Id = hashStr(0, "Workspace");
    w.* = .{
        .id = id,
        .pos = .{ 0, 0 },
        .size = .{ 400, 800 },
        .user_resized = false,
    };
    ctx.windows.put(ctx.gpa, id, w) catch unreachable;

    const ui_ = Ui{ .ctx = &ctx };
    ui_.setWindowSize("Workspace", .{ 500, 900 });
    try expectEqual(@as(Vec2, .{ 500, 900 }), w.size);
    try expectEqual(true, w.user_resized);
}

test "setWindow*-by-name: setWindowFocus sets focused_window_id" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w = ctx.gpa.create(Window) catch unreachable;
    const id: Id = hashStr(0, "Workspace");
    w.* = .{
        .id = id,
        .pos = .{ 0, 0 },
        .size = .{ 400, 800 },
    };
    ctx.windows.put(ctx.gpa, id, w) catch unreachable;
    try expectEqual(@as(Id, 0), ctx.focused_window_id);

    const ui_ = Ui{ .ctx = &ctx };
    ui_.setWindowFocus("Workspace");
    try expectEqual(id, ctx.focused_window_id);
}

test "setWindow*-by-name: no-op on missing window (no panic)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui_ = Ui{ .ctx = &ctx };
    // None of these should crash - windows map is empty.
    ui_.setWindowPos("Nonexistent", .{ 100, 100 });
    ui_.setWindowSize("Nonexistent", .{ 400, 400 });
    ui_.setWindowFocus("Nonexistent");
    try expectEqual(@as(Id, 0), ctx.focused_window_id);
}

test "setNextWindowSizeConstraints: stores min and max on context" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui_ = Ui{ .ctx = &ctx };

    ui_.setNextWindowSizeConstraints(.{ 320, 240 }, .{ 1000, 800 });
    try expectEqual(@as(?Vec2, .{ 320, 240 }), ctx.next_window_size_min);
    try expectEqual(@as(?Vec2, .{ 1000, 800 }), ctx.next_window_size_max);

    // Either bound can be null.
    ui_.setNextWindowSizeConstraints(.{ 100, 100 }, null);
    try expectEqual(@as(?Vec2, .{ 100, 100 }), ctx.next_window_size_min);
    try expectEqual(@as(?Vec2, null), ctx.next_window_size_max);
}

// ============================================================================
//: TreeNodeEx flag tests
// ============================================================================
// Headless tests for treeNodeExImpl. Uses the same
// `splitterTestSetup` pattern: synthesizes a Window + sets
// current_window directly, then drives treeNodeExImpl with
// `default_open` / `leaf` / `setNextItemOpen` / etc. and asserts
// the resulting open-state and persistence.

test "TreeNodeEx: leaf returns true unconditionally, no toggle" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);

    const open_a: bool = treeNodeExImpl(&ctx, "leaf-a", .{ .leaf = true });
    try expectEqual(true, open_a);

    // Click on the leaf: should NOT toggle anything.
    ctx.input.mouse_pos = .{ 12, 12 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = treeNodeExImpl(&ctx, "leaf-b", .{ .leaf = true });
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_left_released = true;
    const open_b: bool = treeNodeExImpl(&ctx, "leaf-b", .{ .leaf = true });
    // Still true (leaf always returns true), no stored state.
    try expectEqual(true, open_b);
    // No entry in tree_open_state for leaves.
    const id: Id = widgetId(&ctx, ctx.current_window.?, "leaf-b");
    try expectEqual(@as(?bool, null), ctx.tree_open_state.get(id));
}

test "TreeNodeEx: default_open opens on first encounter" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);

    const open: bool = treeNodeExImpl(&ctx, "folder", .{ .default_open = true });
    try expectEqual(true, open);
    const id: Id = widgetId(&ctx, ctx.current_window.?, "folder");
    try expectEqual(@as(?bool, true), ctx.tree_open_state.get(id));
}

test "TreeNodeEx: no default_open starts closed" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);
    const open: bool = treeNodeExImpl(&ctx, "folder", .{});
    try expectEqual(false, open);
}

test "TreeNodeEx: setNextItemOpen overrides stored state" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);

    // First encounter, default closed.
    _ = treeNodeExImpl(&ctx, "folder", .{});
    const id: Id = widgetId(&ctx, ctx.current_window.?, "folder");
    try expectEqual(@as(?bool, null), ctx.tree_open_state.get(id));

    // Force open.
    ctx.next_item_open = true;
    const open: bool = treeNodeExImpl(&ctx, "folder", .{});
    try expectEqual(true, open);
    try expectEqual(@as(?bool, true), ctx.tree_open_state.get(id));
    // Consumed.
    try expectEqual(@as(?bool, null), ctx.next_item_open);
}

test "TreeNodeEx: isItemToggledOpen surfaces toggle edge" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);

    // First call: not toggled (just initial submission).
    _ = treeNodeExImpl(&ctx, "folder", .{});
    try expectEqual(false, ctx.item_just_toggled);

    // Simulate click on the node. Cursor at (8,8); node rect
    // starts at cursor, height = font_size (10) + 2*0 = 10
    // (no frame_padding), width = tri_size+4+text_width.
    // Click at (12, 12) is inside.
    w.layout.cursor_pos = .{ 8, 8 };
    w.layout.line_height = 0;
    ctx.input.mouse_pos = .{ 12, 12 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = treeNodeExImpl(&ctx, "folder", .{});
    // Press-down doesn't toggle yet - toggle is on release.
    try expectEqual(false, ctx.item_just_toggled);

    // Release.
    w.layout.cursor_pos = .{ 8, 8 };
    w.layout.line_height = 0;
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_left_down = false;
    ctx.input.mouse_left_released = true;
    _ = treeNodeExImpl(&ctx, "folder", .{});
    try expectEqual(true, ctx.item_just_toggled);
}

test "treeNodeGetOpen: queries stored state without rendering" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);
    const ui_ = Ui{ .ctx = &ctx };

    // No state stored → false.
    try expectEqual(false, ui_.treeNodeGetOpen("folder"));

    // Open the node, then query - should report true.
    _ = treeNodeExImpl(&ctx, "folder", .{ .default_open = true });
    try expectEqual(true, ui_.treeNodeGetOpen("folder"));
}

test "getTreeNodeToLabelSpacing: returns tri_size + 4" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const ui_ = Ui{ .ctx = &ctx };
    // Default font_size = 10 (or whatever zimr's default is).
    const spacing: f32 = ui_.getTreeNodeToLabelSpacing();
    try expectApproxEqAbs(ctx.style.font_size + 4, spacing, 0.01);
}

test "TreeNodeEx: backward compat - plain treeNode still works" {
    // Pre-Step-1.5 callers used u.treeNode(label,.{}) which now forwards
    // to treeNodeExImpl(label,.{}). Behavior should be identical
    // to a no-flags TreeNodeEx call.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    _ = splitterTestSetup(&ctx);

    const open: bool = treeNodeExImpl(&ctx, "folder", .{});
    try expectEqual(false, open); // no default_open
    const id: Id = widgetId(&ctx, ctx.current_window.?, "folder");
    // No state stored on first call without flags.
    try expectEqual(@as(?bool, null), ctx.tree_open_state.get(id));
}

//: regression tests for the sameLine fix
// (previously, sameLine left cursor_pos[1] past the previous widget's
// bottom - widgets ended up vertically stacked rather than on the
// same row). Imgui equivalent: SameLine() restores
// CursorPos.y = CursorPosPrevLine.y.

test "sameLine: keeps next widget on the same row as previous" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    w.layout.cursor_pos = .{ 8, 8 };

    const u = Ui{ .ctx = &ctx };
    _ = u.button("A", .{});
    const a_y: f32 = w.layout.last_item_rect.y;
    u.sameLine(.{});
    _ = u.button("B", .{});
    const b_y: f32 = w.layout.last_item_rect.y;
    try expectEqual(a_y, b_y);
}

test "sameLine after endChild: next widget at child's row" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    w.layout.cursor_pos = .{ 8, 8 };

    const u = Ui{ .ctx = &ctx };
    if (u.beginChild("L", .{ 100, 200 }, .{})) {
        defer u.endChild();
        _ = u.button("inside", .{});
    }
    const child_y: f32 = w.layout.last_item_rect.y;
    u.sameLine(.{});
    _ = u.button("after", .{});
    const after_y: f32 = w.layout.last_item_rect.y;
    // "after" must be at the SAME Y as the child it sits beside.
    try expectEqual(child_y, after_y);
}

test "beginChild: second widget renders at child's inner-left, not parent's" {
    // Regression for the left-clip bug discovered:
    // advanceLayout used `w.pos[0] + window_pad` for its next-row
    // cursor reset, ignoring which child was active. Widgets inside
    // a child anchored away from parent's left edge wrapped back to
    // the parent's left on every row.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    w.pos = .{ 0, 0 };
    w.layout.origin[0] = 8; // window content left
    w.layout.cursor_pos = .{ 200, 8 }; // simulate cursor positioned after sameLine
    w.layout.last_item_rect = .{ .x = 100, .y = 8, .width = 100, .height = 20 };

    const u = Ui{ .ctx = &ctx };
    if (u.beginChild("right", .{ 150, 100 }, .{})) {
        defer u.endChild();
        _ = u.button("first", .{});
        const first_x: f32 = w.layout.last_item_rect.x;
        _ = u.button("second", .{});
        const second_x: f32 = w.layout.last_item_rect.x;
        // Second widget must align with the FIRST inside the child
        // NOT bounce back to the parent window's content-left at x=8.
        try expectEqual(first_x, second_x);
    }
}

test "beginChild auto-fill: nested child bounds to PARENT child's right edge, not window's" {
    // Regression for the right-pane spillover bug seen:
    // an `editor` child with size.{0, h} inside a narrow `right`
    // child filled to the TOP-LEVEL WINDOW's right edge, blowing
    // past the right child's right edge. Fixed by
    // routing the auto-fill through `LayoutScope.work_rect_max`.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    // Simulate a workspace at 400 wide, sample a "right" pane
    // that's narrow (20 wide) at x=340.
    w.pos = .{ 0, 0 };
    w.size = .{ 400, 600 };
    w.layout.origin = .{ 8, 8 };
    w.layout.work_rect_max = .{ 392, 592 }; // workspace's inner-bottom-right
    w.layout.cursor_pos = .{ 340, 8 };

    const u = Ui{ .ctx = &ctx };
    if (u.beginChild("right", .{ 20, 200 }, .{})) {
        defer u.endChild();
        // After's child-as-Window refactor, the child's
        // outer rect is `current_window.pos` + `.size` - the child
        // IS a Window now.
        const right_child: *Window = ctx.current_window.?;
        const right_pane_right: f32 = right_child.pos[0] + right_child.size[0];
        // Now an editor inside with auto-fill width.
        if (u.beginChild("editor", .{ 0, 100 }, .{})) {
            defer u.endChild();
            const editor: *Window = ctx.current_window.?;
            const editor_right: f32 = editor.pos[0] + editor.size[0];
            // Editor's right edge MUST NOT exceed the right child's
            // right edge. Before the fix, this assertion failed by
            // ~360 pixels.
            try expect(editor_right <= right_pane_right);
        }
    }
}

test "beginChild: explicit oversized width clamped to parent scope" {
    // Defensive structural fix: even when user math is
    // wrong - e.g. forgot to account for `sameLine` spacing - the
    // child's actual rect is clamped to its parent's `work_rect_max`.
    // Before the clamp, ui_panes's right pane extended ~16px past
    // the workspace edge (the bug the user photographed).
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    w.pos = .{ 0, 0 };
    w.size = .{ 400, 600 };
    w.layout.origin = .{ 8, 8 };
    w.layout.work_rect_max = .{ 392, 592 }; // workspace's inner-bottom-right
    // Place cursor at x=380, then ask for a child 50 pixels wide.
    // 380 + 50 = 430 - that's 38 pixels past work_rect_max[0]=392.
    // The clamp must trim actual_size[0] to 12 (= 392 - 380).
    w.layout.cursor_pos = .{ 380, 8 };
    const parent_right: f32 = w.layout.work_rect_max[0];

    const u = Ui{ .ctx = &ctx };
    if (u.beginChild("oversized", .{ 50, 100 }, .{})) {
        defer u.endChild();
        // After, the child is a real Window; its outer
        // rect is `child.pos` + `child.size`.
        const child: *Window = ctx.current_window.?;
        const child_right: f32 = child.pos[0] + child.size[0];
        // Right edge of the child must not exceed parent's
        // work_rect_max[0] (captured before beginChild).
        try expect(child_right <= parent_right);
        // Specifically: clamped to exactly 12 pixels.
        try expectApproxEqAbs(@as(f32, 12), child.size[0], 0.1);
    }
}

test "row max-height: sameLine across mixed heights clears the tallest" {
    // Latent bug discovered during architecture discussion (turn
    // 308.5): if widgets on a sameLine row have different heights
    // (A=20, B=10, C=15), the row's next-wrap must clear the
    // TALLEST widget on the row (20), not just the last one (15).
    // imgui's `PrevLineSize.y` tracks the row's max; zimr's
    // `LayoutScope.line_height` accumulates and is promoted to
    // `prev_line_height` on advance.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const w: *Window = splitterTestSetup(&ctx);
    w.layout.cursor_pos = .{ 0, 0 };
    w.layout.origin = .{ 0, 0 };
    w.layout.cursor_pos_prev_line = .{ 0, 0 };

    // Simulate row of three widgets at heights 20, 10, 15 via
    // direct advanceLayout calls (skipping widget construction so
    // we can pin down the row-height contract in isolation).
    advanceLayout(w, .{ 50, 20 }, .{ 4, 4 }, 8); // widget A, h=20
    const u = Ui{ .ctx = &ctx };
    u.sameLine(.{});
    advanceLayout(w, .{ 30, 10 }, .{ 4, 4 }, 8); // widget B, h=10 (sameLine)
    u.sameLine(.{});
    advanceLayout(w, .{ 40, 15 }, .{ 4, 4 }, 8); // widget C, h=15 (sameLine)

    // After C's advance, cursor.y should clear the TALLEST widget
    // on the row (A's 20), not just C's 15. Expected next-row Y:
    //   row_top (0) + max_height (20) + spacing (4) = 24.
    try expectApproxEqAbs(@as(f32, 24), w.layout.cursor_pos[1], 0.1);
}

test "cursor_pos_prev_line: seeded at window-open inner origin" {
    // A `sameLine` call before ANY widget submission must not read
    // an uninitialized (0, 0) cursor_pos_prev_line. openWindow
    // and beginChild both seed it to inner_origin. This test
    // documents the contract; the actual openWindow path requires
    // a full UiContext + GL setup, so we exercise the same seeding
    // via beginChild.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const parent: *Window = splitterTestSetup(&ctx);
    parent.layout.cursor_pos = .{ 8, 8 };

    const u = Ui{ .ctx = &ctx };
    if (u.beginChild("seed-check", .{ 100, 100 }, .{})) {
        defer u.endChild();
        // After, inside beginChild, `current_window` is
        // the CHILD Window with its own layout. No widget
        // submitted yet - child's cursor_pos_prev_line must match
        // its inner origin (not (0,0) from default init).
        const child: *Window = ctx.current_window.?;
        try expectEqual(child.layout.origin[0], child.layout.cursor_pos_prev_line[0]);
        try expectEqual(child.layout.origin[1], child.layout.cursor_pos_prev_line[1]);
        // And neither is 0 (the buggy default).
        try expect(child.layout.cursor_pos_prev_line[0] > 0);
        try expect(child.layout.cursor_pos_prev_line[1] > 0);
    }
}

// ============================================================================
//.i + d.ii (turns 321-322) - title-bar drag tracks dragging_window
// with cumulative-motion threshold
// ============================================================================

test "5.5d.ii: dock-drag threshold gates dragging_window (null below, set above)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    ctx.canvas_w = 800;
    ctx.canvas_h = 600;

    // Build a single window via the normal path so it has a
    // populated id, pos, size, name, etc. Submit one frame to
    // register the window in `ctx.windows`.
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};

    // Frame 1: register the window.
    {
        const u: Ui = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = u.window("Draggy", .{
            .initial_pos = .{ 100, 100 },
            .initial_size = .{ 200, 150 },
        }) orelse return error.NoWindow;
        h.close();
    }
    // Sanity: dragging_window starts as null (no drag).
    try expectEqual(@as(?Id, null), ctx.dock.dragging_window);

    // Frame 2: mouse down inside the title bar; this triggers the
    // drag-begin path. No motion yet - `dragging_window` must
    // remain null because we haven't passed dock_drag_threshold_px.
    const w: *Window = ctx.windows.values()[0];
    const w_id: Id = w.id;
    const press_pos: Vec2 = .{ w.pos[0] + 20, w.pos[1] + 8 };
    ctx.input.mouse_pos = press_pos;
    ctx.input.mouse_left_down = true;
    ctx.input.mouse_left_clicked = true;
    {
        const u: Ui = ctx.beginFrameRaw(ctx.input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = u.window("Draggy", .{}) orelse return error.NoWindow;
        h.close();
    }
    try expectEqual(@as(?Id, null), ctx.dock.dragging_window);

    // Frame 3: mouse moved past the 8 px threshold. Now
    // `dragging_window` must reflect this window's id.
    ctx.input.mouse_pos = .{ press_pos[0] + 20, press_pos[1] };
    ctx.input.mouse_left_clicked = false; // press already happened
    {
        const u: Ui = ctx.beginFrameRaw(ctx.input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = u.window("Draggy", .{}) orelse return error.NoWindow;
        h.close();
    }
    try expectEqual(@as(?Id, w_id), ctx.dock.dragging_window);

    // Frame 4: mouse released - dragging_window must clear.
    ctx.input.mouse_left_down = false;
    ctx.input.mouse_left_clicked = false;
    {
        const u: Ui = ctx.beginFrameRaw(ctx.input, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
        const h: WindowHandle = u.window("Draggy", .{}) orelse return error.NoWindow;
        h.close();
    }
    try expectEqual(@as(?Id, null), ctx.dock.dragging_window);
}

// ============================================================================
//.iv - drop hit-test enqueues DockRequest on release
// ============================================================================

test "5.5d.iv: dockTargetZonesFor returns null for tiny leaves" {
    // At font_size 12, the cross needs ~hs*5 ≈ 60+ px min dim.
    // A 30×30 leaf is comfortably below threshold.
    try expectEqual(@as(?[5]Rectangle, null), dockTargetZonesFor(.{ 0, 0 }, .{ 30, 30 }, 12));
}

test "5.5d.iv: dockTargetZonesFor centers the cross on the leaf" {
    const zones: [5]Rectangle = dockTargetZonesFor(.{ 100, 100 }, .{ 400, 300 }, 14) orelse return error.TooSmall;
    // Leaf center: (300, 250). Center zone (index 0) is centered
    // there regardless of exact size - assert center-of-zone matches
    // center-of-leaf, not exact dimensions (which depend on font_size).
    const center_zone_cx: f32 = zones[0].x + zones[0].width * 0.5;
    const center_zone_cy: f32 = zones[0].y + zones[0].height * 0.5;
    try expectApproxEqAbs(@as(f32, 300), center_zone_cx, 1.0);
    try expectApproxEqAbs(@as(f32, 250), center_zone_cy, 1.0);
    // Center zone is square and ~10% bigger than edge zones.
    try expectApproxEqAbs(zones[0].width, zones[0].height, 0.5);
    try expect(zones[0].width > zones[1].width);
}

test "5.5d.iv: hitTestDockTargets returns center for mouse on center zone" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 100, 100 };
    leaf.size = .{ 400, 300 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    // Mouse exactly at leaf center → center zone wins (score ~1.0).
    const hit: DockTargetHit = hitTestDockTargets(&ctx, .{ 300, 250 }) orelse return error.MissedHit;
    try expectEqual(DockTargetKind.center, hit.kind);
    try expectEqual(leaf_id, hit.target_node);
}

test "5.5d.iv: hitTestDockTargets returns right for mouse near right zone center" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 0, 0 };
    leaf.size = .{ 400, 300 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    // Compute the right zone's actual center using the helper, then
    // probe with the mouse exactly there. Independent of font_size
    // and adaptive sizing.
    const zones: [5]Rectangle = dockTargetZonesFor(
        leaf.pos,
        leaf.size,
        ctx.style.font_size,
    ) orelse return error.TooSmall;
    const right_cx: f32 = zones[2].x + zones[2].width * 0.5;
    const right_cy: f32 = zones[2].y + zones[2].height * 0.5;
    const hit: DockTargetHit = hitTestDockTargets(&ctx, .{ right_cx, right_cy }) orelse return error.MissedHit;
    try expectEqual(DockTargetKind.right, hit.kind);
}

test "5.5d.iv: hitTestDockTargets returns null when mouse is far from any zone" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 0, 0 };
    leaf.size = .{ 400, 300 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    // Far corner of the leaf - way outside every square's pull radius, so the drop
    // floats (docking only happens ON a square).
    try expectEqual(@as(?DockTargetHit, null), hitTestDockTargets(&ctx, .{ 5, 5 }));
}

test "5.5e: dockZoneScore peaks at center, decays smoothly" {
    const z: Rectangle = .{ .x = 100, .y = 100, .width = 40, .height = 40 };
    // Mouse at zone center → score = 1.0
    try expectApproxEqAbs(@as(f32, 1.0), dockZoneScore(z, .{ 120, 120 }, 1.0), 0.001);
    // Mouse at exactly pull_radius distance → score = 0
    const diag_half = @sqrt(@as(f32, 40 * 40 + 40 * 40)) * 0.5; // ~28.28
    try expectApproxEqAbs(@as(f32, 0.0), dockZoneScore(z, .{ 120 + diag_half, 120 }, 1.0), 0.001);
    // Mouse far away → score = 0
    try expectEqual(@as(f32, 0), dockZoneScore(z, .{ 1000, 1000 }, 1.0));
    // Mouse halfway to pull radius → score ~0.5
    try expectApproxEqAbs(@as(f32, 0.5), dockZoneScore(z, .{ 120 + diag_half * 0.5, 120 }, 1.0), 0.01);
}

test "5.5e: closest zone wins (no ambiguity between adjacent zones)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 0, 0 };
    leaf.size = .{ 400, 300 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    const zones: [5]Rectangle = dockTargetZonesFor(
        leaf.pos,
        leaf.size,
        ctx.style.font_size,
    ) orelse return error.TooSmall;
    // Probe at a point that's between center and right but closer
    // to right. Old rect-contains would return "no hit" if the
    // point sat in the gap; new score-based logic gives right.
    const center_cx: f32 = zones[0].x + zones[0].width * 0.5;
    const center_cy: f32 = zones[0].y + zones[0].height * 0.5;
    const right_cx: f32 = zones[2].x + zones[2].width * 0.5;
    // 75% of the way from center to right.
    const probe_x: f32 = center_cx + (right_cx - center_cx) * 0.75;
    const hit: DockTargetHit = hitTestDockTargets(&ctx, .{ probe_x, center_cy }) orelse return error.MissedHit;
    try expectEqual(DockTargetKind.right, hit.kind);
}

test "5.5h: pre-dock pos/size stashed on first dock via dockBuilder" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });

    // Create the window with a recognizable pos/size.
    const w: *Window = findOrCreateWindow(&ctx, "PreDockTest", .{
        .initial_pos = .{ 50, 60 },
        .initial_size = .{ 200, 150 },
    });
    // Sanity: stash empty before any dock.
    try expectEqual(@as(?Vec2, null), w.pre_dock_pos);

    u.dockBuilderDockWindow("PreDockTest", leaf_id);

    // After the first dock, the stash should hold the original
    // floating pos/size.
    try expectEqual(@as(?Vec2, .{ 50, 60 }), w.pre_dock_pos);
    try expectEqual(@as(?Vec2, .{ 200, 150 }), w.pre_dock_size);
}

test "5.5h: pre-dock stash NOT overwritten by re-dock" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_a: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf_b: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });

    const w: *Window = findOrCreateWindow(&ctx, "ReDockTest", .{
        .initial_pos = .{ 10, 20 },
        .initial_size = .{ 300, 200 },
    });

    // First dock: stash is set to (10, 20) / (300, 200).
    u.dockBuilderDockWindow("ReDockTest", leaf_a);
    try expectEqual(@as(?Vec2, .{ 10, 20 }), w.pre_dock_pos);

    // Simulate the window having been moved while docked
    // (shouldn't happen in practice, but exercises the "no
    // re-stash" invariant - moving to leaf_b must NOT overwrite
    // the pre-dock anchor with a docked pos).
    w.pos = .{ 999, 999 };
    u.dockBuilderDockWindow("ReDockTest", leaf_b);
    try expectEqual(@as(?Vec2, .{ 10, 20 }), w.pre_dock_pos);
    try expectEqual(@as(?Vec2, .{ 300, 200 }), w.pre_dock_size);
}

test "5.5h: undock via dockBuilderRemoveNode restores pre-dock rect" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });

    const w: *Window = findOrCreateWindow(&ctx, "UndockTest", .{
        .initial_pos = .{ 80, 90 },
        .initial_size = .{ 250, 175 },
    });
    u.dockBuilderDockWindow("UndockTest", leaf_id);

    // While docked, the window's effective rect is the leaf's
    // (driven each frame by findOrCreateWindow). Simulate that.
    w.pos = .{ 0, 0 };
    w.size = .{ 1000, 500 };

    // Undock: clear via the dock-builder removal path.
    u.dockBuilderRemoveNode(leaf_id);

    // Window should be back at its pre-dock rect, and the
    // anchor should be cleared (ready to re-stash on next dock).
    try expectEqual(@as(Vec2, .{ 80, 90 }), w.pos);
    try expectEqual(@as(Vec2, .{ 250, 175 }), w.size);
    try expectEqual(@as(?Vec2, null), w.pre_dock_pos);
    try expectEqual(@as(?Vec2, null), w.pre_dock_size);
    try expectEqual(@as(?Id, null), w.dock_node_id);
}

test "5.5f: DockNodeFlags packed struct round-trips through u16" {
    const flags: DockNodeFlags = .{
        .no_split = true,
        .no_tab_bar = true,
        .is_central = true,
    };
    const bits: u16 = @bitCast(flags);
    const back: DockNodeFlags = @bitCast(bits);
    try expect(back.no_split);
    try expect(back.no_tab_bar);
    try expect(back.is_central);
    try expect(!back.no_resize);
    try expect(!back.is_dockspace);
}

test "5.5f: transfer_mask passes leaf-affinity, blocks tree-structural" {
    const all: DockNodeFlags = .{
        .no_split = true,
        .no_tab_bar = true,
        .hidden_tab_bar = true,
        .no_resize = true,
        .no_docking_over_me = true,
        .no_close_button = true,
        .passthru_central = true,
        .is_central = true,
        .is_dockspace = true,
    };
    const transferred: DockNodeFlags = all.intersect(DockNodeFlags.transfer_mask);
    // Leaf-affinity bits move:
    try expect(transferred.no_split);
    try expect(transferred.no_tab_bar);
    try expect(transferred.hidden_tab_bar);
    try expect(transferred.no_resize);
    try expect(transferred.no_docking_over_me);
    try expect(transferred.no_close_button);
    try expect(transferred.is_central);
    // Tree-structural bits stay home:
    try expect(!transferred.passthru_central);
    try expect(!transferred.is_dockspace);
}

test "5.5f: splitNode transfers leaf-affinity flags to inheritor" {
    var dock_ctx: DockContext = .{};
    defer dock_ctx.deinit(std.testing.allocator);

    const root: Id = try dock_ctx.createLeaf(std.testing.allocator, null);
    // Configure the leaf with constraints + a tree-structural flag.
    dock_ctx.lookup(root).?.flags = .{
        .no_split = true,
        .no_close_button = true,
        .is_dockspace = true,
    };

    // Split: existing content goes to child_b (since dir=.right
    // means new content on the right, existing on left → child_a
    // for left = first_child = true... wait, let me match the
    // existing convention). See Dir.isFirstChild and the
    // existing 5.5d test setup.
    const r: SplitResult = try splitNode(&dock_ctx, std.testing.allocator, root, .right, 0.5);

    // Parent (now a split node) keeps is_dockspace but loses
    // leaf-affinity flags.
    const parent: *DockNode = dock_ctx.lookup(root).?;
    try expect(parent.flags.is_dockspace);
    try expect(!parent.flags.no_split);
    try expect(!parent.flags.no_close_button);

    // Inheritor (child_a - existing went to the LEFT for.right
    // because `isFirstChild()` is false for.right, so
    // existing_goes_to_a is true) carries the leaf flags.
    const inheritor: *DockNode = dock_ctx.lookup(r.a).?;
    try expect(inheritor.flags.no_split);
    try expect(inheritor.flags.no_close_button);
    try expect(!inheritor.flags.is_dockspace);

    // Fresh child gets default flags (no constraints).
    const fresh: *DockNode = dock_ctx.lookup(r.b).?;
    try expect(!fresh.flags.no_split);
    try expect(!fresh.flags.no_close_button);
}

test "5.5f: no_docking_over_me blocks the hit-test entirely" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 0, 0 };
    leaf.size = .{ 400, 300 };
    leaf.flags = .{ .no_docking_over_me = true };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    // Mouse dead center of leaf - would normally hit center zone.
    try expectEqual(@as(?DockTargetHit, null), hitTestDockTargets(&ctx, .{ 200, 150 }));
}

test "5.5f: no_split allows center but blocks side zones" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    leaf.pos = .{ 0, 0 };
    leaf.size = .{ 400, 300 };
    leaf.flags = .{ .no_split = true };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    const zones: [5]Rectangle = dockTargetZonesFor(
        leaf.pos,
        leaf.size,
        ctx.style.font_size,
    ) orelse return error.TooSmall;

    // Center still works.
    const center_hit: DockTargetHit = hitTestDockTargets(&ctx, .{
        zones[0].x + zones[0].width * 0.5,
        zones[0].y + zones[0].height * 0.5,
    }) orelse return error.MissedCenter;
    try expectEqual(DockTargetKind.center, center_hit.kind);

    // Right zone center is a no-hit on a no_split leaf: the side score is zeroed
    // and there is no region fallback, so a drop there floats instead of docking.
    try expectEqual(@as(?DockTargetHit, null), hitTestDockTargets(&ctx, .{
        zones[2].x + zones[2].width * 0.5,
        zones[2].y + zones[2].height * 0.5,
    }));
}

test "runtime dock: enqueue + processRequests + sync links the window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const w: *Window = findOrCreateWindow(&ctx, "Editor", .{});
    const editor_id: Id = w.id;
    try expectEqual(@as(?Id, null), w.dock_node_id); // starts floating

    // Exactly what the title-bar release handler enqueues for a center drop.
    try ctx.dock.pending_requests.append(ctx.gpa, .{ .dock_as_tab = .{
        .target_node = leaf_id,
        .source_window = editor_id,
    } });
    // Exactly what endFrame does.
    try processRequests(&ctx.dock, ctx.gpa);
    syncDockedWindowIds(&ctx);

    const leaf: *DockNode = ctx.dock.lookup(leaf_id).?;
    try expect(leaf.leaf.?.indexOf(editor_id) != null); // in the tab list
    try expectEqual(@as(?Id, leaf_id), w.dock_node_id); // window linked
}

test "runtime dock: dock_as_split links the source into the new child" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const root: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const editor: *Window = findOrCreateWindow(&ctx, "Editor", .{});
    try dockWindowAsTab(&ctx.dock, ctx.gpa, root, editor.id);
    const files: *Window = findOrCreateWindow(&ctx, "Files", .{});

    // What the release handler enqueues for an edge (bottom) drop.
    try ctx.dock.pending_requests.append(ctx.gpa, .{ .dock_as_split = .{
        .target_node = root,
        .source_window = files.id,
        .dir = .bottom,
    } });
    try processRequests(&ctx.dock, ctx.gpa);
    syncDockedWindowIds(&ctx);

    const root_node: *DockNode = ctx.dock.lookup(root).?;
    try expect(root_node.isSplit()); // root became a split
    try expect(files.dock_node_id != null); // Files linked
    try expect(editor.dock_node_id != null); // Editor still linked
    const files_leaf: *DockNode = ctx.dock.lookup(files.dock_node_id.?).?;
    try expect(files_leaf.leaf.?.indexOf(files.id) != null);
}

test "runtime dock: dock_as_split into a CHILD leaf (the live case)" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    // root split: a=Outline leaf, b=Editor leaf (Files floating).
    const root: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const outline: *Window = findOrCreateWindow(&ctx, "Outline", .{});
    try dockWindowAsTab(&ctx.dock, ctx.gpa, root, outline.id);
    const sr: SplitResult = try splitNode(&ctx.dock, ctx.gpa, root, .right, 0.3);
    const editor: *Window = findOrCreateWindow(&ctx, "Editor", .{});
    try dockWindowAsTab(&ctx.dock, ctx.gpa, sr.b, editor.id);
    const files: *Window = findOrCreateWindow(&ctx, "Files", .{});

    // Drop Files on the bottom of the Editor CHILD leaf.
    try ctx.dock.pending_requests.append(ctx.gpa, .{ .dock_as_split = .{
        .target_node = sr.b,
        .source_window = files.id,
        .dir = .bottom,
    } });
    try processRequests(&ctx.dock, ctx.gpa);
    syncDockedWindowIds(&ctx);

    try expect(files.dock_node_id != null);
    const fl: *DockNode = ctx.dock.lookup(files.dock_node_id.?).?;
    try expect(fl.leaf.?.indexOf(files.id) != null);
    try expect(editor.dock_node_id != null);
}

test "5.5f: dockBuilderSetNodeFlags / getNodeFlags round-trip" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });

    // Default: all false.
    const before: DockNodeFlags = u.dockBuilderGetNodeFlags(leaf_id);
    try expect(!before.no_split);
    try expect(!before.no_tab_bar);

    u.dockBuilderSetNodeFlags(leaf_id, .{
        .no_split = true,
        .no_tab_bar = true,
    });

    const after: DockNodeFlags = u.dockBuilderGetNodeFlags(leaf_id);
    try expect(after.no_split);
    try expect(after.no_tab_bar);
    try expect(!after.no_resize);

    // Unknown id returns default-init flags (no crash).
    const ghost: DockNodeFlags = u.dockBuilderGetNodeFlags(999_999);
    try expect(!ghost.no_split);
}

test "5.5i: dockSplitterSeamRect places the seam at the boundary" {
    var dock_ctx: DockContext = .{};
    defer dock_ctx.deinit(std.testing.allocator);

    const root: Id = try dock_ctx.createLeaf(std.testing.allocator, null);
    _ = try splitNode(&dock_ctx, std.testing.allocator, root, .right, 0.25);
    layoutSubtree(&dock_ctx, root, .{ 100, 50 }, .{ 800, 400 });

    const root_node: *DockNode = dock_ctx.lookup(root).?;
    const seam: Rectangle = dockSplitterSeamRect(root_node);
    const avail: f32 = 800 - splitter_size;
    // Seam x = root.pos[0] + avail * 0.25.
    try expectApproxEqAbs(@as(f32, 100 + avail * 0.25), seam.x, 0.001);
    try expectApproxEqAbs(@as(f32, 50), seam.y, 0.001);
    try expectApproxEqAbs(splitter_size, seam.width, 0.001);
    try expectApproxEqAbs(@as(f32, 400), seam.height, 0.001);
}

test "5.5i: splitter drag updates ratio" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const root: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    _ = try splitNode(&ctx.dock, ctx.gpa, root, .right, 0.30);
    layoutSubtree(&ctx.dock, root, .{ 0, 0 }, .{ 800, 400 });
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, root);

    const root_node: *DockNode = ctx.dock.lookup(root).?;
    const initial_seam: Rectangle = dockSplitterSeamRect(root_node);
    const initial_x: f32 = initial_seam.x + initial_seam.width * 0.5;

    // Simulate click on seam: mouse over, mouse_left_clicked.
    ctx.input.mouse_pos = .{ initial_x, 200 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = renderDockSplittersImpl(&ctx, root);
    // active_id should be set.
    const expected_id: Id = hashStr(root, "__dock_splitter");
    try expectEqual(expected_id, ctx.active_id);

    // Next frame: still down, mouse moved 200 px right.
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ initial_x + 200, 200 };
    _ = renderDockSplittersImpl(&ctx, root);
    // Ratio should have increased. Mouse at (initial_x + 200) +
    // pivot adjustment gives ~ (avail * 0.3 + 200) / avail ≈ 0.55.
    const new_ratio: f32 = root_node.split.?.ratio;
    try expect(new_ratio > 0.30);
    try expect(new_ratio < 0.70);

    // Release.
    ctx.input.mouse_left_down = false;
    ctx.input.mouse_left_released = true;
    _ = renderDockSplittersImpl(&ctx, root);
    try expectEqual(@as(Id, 0), ctx.active_id);
}

test "5.5i: no_resize flag blocks splitter interaction" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const root: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    _ = try splitNode(&ctx.dock, ctx.gpa, root, .right, 0.30);
    ctx.dock.lookup(root).?.flags = .{ .no_resize = true };
    layoutSubtree(&ctx.dock, root, .{ 0, 0 }, .{ 800, 400 });
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, root);

    const root_node: *DockNode = ctx.dock.lookup(root).?;
    const seam: Rectangle = dockSplitterSeamRect(root_node);
    const initial_ratio: f32 = root_node.split.?.ratio;

    // Click on the seam.
    ctx.input.mouse_pos = .{ seam.x + seam.width * 0.5, 200 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = renderDockSplittersImpl(&ctx, root);

    // active_id should NOT have been claimed.
    try expectEqual(@as(Id, 0), ctx.active_id);

    // Even if we forced active_id, drag wouldn't fire - but
    // simpler: ratio unchanged after the call.
    try expectEqual(initial_ratio, root_node.split.?.ratio);
}

test "5.5g: size_ref on child a locks its size, b absorbs remainder" {
    var dock_ctx: DockContext = .{};
    defer dock_ctx.deinit(std.testing.allocator);

    const root: Id = try dock_ctx.createLeaf(std.testing.allocator, null);
    _ = try splitNode(&dock_ctx, std.testing.allocator, root, .right, 0.5);
    // Lock child a at 200 px.
    dock_ctx.lookup(root).?.split.?.size_ref[0] = 200;

    // 1000-px dockspace: a = 200, b = 1000 - 4 - 200 = 796.
    layoutSubtree(&dock_ctx, root, .{ 0, 0 }, .{ 1000, 400 });
    const root_node: *DockNode = dock_ctx.lookup(root).?;
    const a_node: *DockNode = dock_ctx.lookup(root_node.split.?.child_ids[0]).?;
    const b_node: *DockNode = dock_ctx.lookup(root_node.split.?.child_ids[1]).?;
    try expectApproxEqAbs(@as(f32, 200), a_node.size[0], 0.001);
    try expectApproxEqAbs(@as(f32, 796), b_node.size[0], 0.001);

    // Grow the dockspace to 1400 px. a STAYS at 200; b grows to 1196.
    layoutSubtree(&dock_ctx, root, .{ 0, 0 }, .{ 1400, 400 });
    try expectApproxEqAbs(@as(f32, 200), a_node.size[0], 0.001);
    try expectApproxEqAbs(@as(f32, 1196), b_node.size[0], 0.001);

    // Shrink the dockspace to 600 px. a still 200; b shrinks to 396.
    layoutSubtree(&dock_ctx, root, .{ 0, 0 }, .{ 600, 400 });
    try expectApproxEqAbs(@as(f32, 200), a_node.size[0], 0.001);
    try expectApproxEqAbs(@as(f32, 396), b_node.size[0], 0.001);
}

test "5.5g: size_ref on child b locks its size, a absorbs remainder" {
    var dock_ctx: DockContext = .{};
    defer dock_ctx.deinit(std.testing.allocator);

    const root: Id = try dock_ctx.createLeaf(std.testing.allocator, null);
    _ = try splitNode(&dock_ctx, std.testing.allocator, root, .right, 0.5);
    // Lock child b (right) at 300 px.
    dock_ctx.lookup(root).?.split.?.size_ref[1] = 300;

    layoutSubtree(&dock_ctx, root, .{ 0, 0 }, .{ 1000, 400 });
    const root_node: *DockNode = dock_ctx.lookup(root).?;
    const a_node: *DockNode = dock_ctx.lookup(root_node.split.?.child_ids[0]).?;
    const b_node: *DockNode = dock_ctx.lookup(root_node.split.?.child_ids[1]).?;
    try expectApproxEqAbs(@as(f32, 696), a_node.size[0], 0.001);
    try expectApproxEqAbs(@as(f32, 300), b_node.size[0], 0.001);
}

test "5.5g: both locks set → falls back to ratio" {
    var dock_ctx: DockContext = .{};
    defer dock_ctx.deinit(std.testing.allocator);

    const root: Id = try dock_ctx.createLeaf(std.testing.allocator, null);
    _ = try splitNode(&dock_ctx, std.testing.allocator, root, .right, 0.25);
    dock_ctx.lookup(root).?.split.?.size_ref[0] = 200; // over-constrained
    dock_ctx.lookup(root).?.split.?.size_ref[1] = 500;

    layoutSubtree(&dock_ctx, root, .{ 0, 0 }, .{ 1000, 400 });
    const root_node: *DockNode = dock_ctx.lookup(root).?;
    const a_node: *DockNode = dock_ctx.lookup(root_node.split.?.child_ids[0]).?;
    // avail = 996, ratio 0.25 → a = 249.
    try expectApproxEqAbs(@as(f32, 249), a_node.size[0], 0.001);
}

test "5.5g: dockBuilderSetSizeRef public API" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const root: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    _ = try splitNode(&ctx.dock, ctx.gpa, root, .right, 0.5);

    u.dockBuilderSetSizeRef(root, .a, 250);
    try expectEqual(@as(?f32, 250), ctx.dock.lookup(root).?.split.?.size_ref[0]);
    try expectEqual(@as(?f32, null), ctx.dock.lookup(root).?.split.?.size_ref[1]);

    // Clear the lock.
    u.dockBuilderSetSizeRef(root, .a, null);
    try expectEqual(@as(?f32, null), ctx.dock.lookup(root).?.split.?.size_ref[0]);

    // No-op on leaf node.
    const lonely_leaf: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    u.dockBuilderSetSizeRef(lonely_leaf, .a, 100); // shouldn't panic
}

test "5.5g: splitter drag on size_ref-locked side updates px" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const root: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    _ = try splitNode(&ctx.dock, ctx.gpa, root, .right, 0.30);
    ctx.dock.lookup(root).?.split.?.size_ref[0] = 250;
    layoutSubtree(&ctx.dock, root, .{ 0, 0 }, .{ 1000, 400 });
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, root);

    const root_node: *DockNode = ctx.dock.lookup(root).?;
    const initial_seam: Rectangle = dockSplitterSeamRect(root_node);

    // Click on the seam (at x=250).
    ctx.input.mouse_pos = .{ initial_seam.x + initial_seam.width * 0.5, 200 };
    ctx.input.mouse_left_clicked = true;
    ctx.input.mouse_left_down = true;
    _ = renderDockSplittersImpl(&ctx, root);

    // Drag 100 px right. Mouse now at ~352 (seam center + 100).
    ctx.input.mouse_left_clicked = false;
    ctx.input.mouse_pos = .{ initial_seam.x + initial_seam.width * 0.5 + 100, 200 };
    _ = renderDockSplittersImpl(&ctx, root);

    // size_ref[0] should have grown by ~100 px; ratio untouched.
    try expect(root_node.split.?.size_ref[0].? > 340);
    try expect(root_node.split.?.size_ref[0].? < 360);
    try expectApproxEqAbs(@as(f32, 0.30), root_node.split.?.ratio, 0.001);
}

test "5.5g: dockBuilderSetCentralNode sets is_central flag" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf: Id = try ctx.dock.createLeaf(ctx.gpa, null);
    try expect(!ctx.dock.lookup(leaf).?.flags.is_central);
    u.dockBuilderSetCentralNode(leaf);
    try expect(ctx.dock.lookup(leaf).?.flags.is_central);
}

test "5.5j: undockSingleWindow restores pre_dock + clears dock_node_id" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const w: *Window = findOrCreateWindow(&ctx, "Closable", .{
        .initial_pos = .{ 30, 40 },
        .initial_size = .{ 200, 150 },
    });
    u.dockBuilderDockWindow("Closable", leaf_id);

    // Simulate docked state - the window's effective rect would
    // be the leaf's during render, but the stash still holds
    // the original floating rect.
    try expectEqual(@as(?Id, leaf_id), w.dock_node_id);
    try expectEqual(@as(?Vec2, .{ 30, 40 }), w.pre_dock_pos);

    undockSingleWindow(&ctx, w.id);

    // After undock: dock id cleared, pre-dock stash drained,
    // window back at original floating rect.
    try expectEqual(@as(?Id, null), w.dock_node_id);
    try expectEqual(@as(?Vec2, null), w.pre_dock_pos);
    try expectEqual(@as(Vec2, .{ 30, 40 }), w.pos);
    try expectEqual(@as(Vec2, .{ 200, 150 }), w.size);

    // Leaf should now have no windows.
    try expectEqual(@as(usize, 0), ctx.dock.lookup(leaf_id).?.leaf.?.window_ids.items.len);
}

test "5.5j: undockSingleWindow is a no-op for non-docked window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const w: *Window = findOrCreateWindow(&ctx, "Floating", .{
        .initial_pos = .{ 10, 20 },
        .initial_size = .{ 100, 80 },
    });
    // Pre-condition: floating.
    try expectEqual(@as(?Id, null), w.dock_node_id);

    undockSingleWindow(&ctx, w.id);

    // Unchanged.
    try expectEqual(@as(?Id, null), w.dock_node_id);
    try expectEqual(@as(Vec2, .{ 10, 20 }), w.pos);
}

test "5.5j: undockSingleWindow ignores unknown window id" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    // Just shouldn't crash.
    undockSingleWindow(&ctx, 999_999);
}

test "5.5j: detachAndStartDrag discards pre_dock_pos, positions under cursor" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const w: *Window = findOrCreateWindow(&ctx, "Detachable", .{
        .initial_pos = .{ 200, 300 },
        .initial_size = .{ 220, 160 },
    });
    u.dockBuilderDockWindow("Detachable", leaf_id);
    // Sanity: docked, pre-dock stash populated.
    try expectEqual(@as(?Id, leaf_id), w.dock_node_id);
    try expectEqual(@as(?Vec2, .{ 200, 300 }), w.pre_dock_pos);

    // Simulate the cursor being at (500, 100) when the user
    // crosses the detach threshold.
    ctx.input.mouse_pos = .{ 500, 100 };
    detachAndStartDrag(&ctx, w.id);

    // After detach:
    //   - dock_node_id cleared
    //   - pre_dock_pos and pre_dock_size dropped (the
    //     window is going to where the cursor is, not back)
    //   - dragging_window set to this window's id
    //   - active_id set to the window's title-bar drag id, so the held-mouse
    //     continue-drag picks up immediately (the fresh-click press can't).
    try expectEqual(@as(?Id, null), w.dock_node_id);
    try expectEqual(@as(?Vec2, null), w.pre_dock_pos);
    try expectEqual(@as(?Vec2, null), w.pre_dock_size);
    try expectEqual(@as(?Id, w.id), ctx.dock.dragging_window);
    try expectEqual(widgetId(&ctx, w, "##drag_title"), ctx.active_id);

    // Position: title bar should be roughly centered horizontally
    // under the cursor and vertically just above it. Cursor was
    // at (500, 100), restored size is (220, 160), title height
    // is `ctx.style.title_bar_height`.
    const title_h: f32 = ctx.style.title_bar_height;
    try expectApproxEqAbs(@as(f32, 500 - 110), w.pos[0], 0.001);
    try expectApproxEqAbs(@as(f32, 100 - title_h * 0.5), w.pos[1], 0.001);
    // Size restored to pre-dock value.
    try expectEqual(@as(Vec2, .{ 220, 160 }), w.size);

    // Leaf is now empty.
    try expectEqual(@as(usize, 0), ctx.dock.lookup(leaf_id).?.leaf.?.window_ids.items.len);
}

test "5.5j: detachAndStartDrag uses w.size when no pre_dock stash" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    const w: *Window = findOrCreateWindow(&ctx, "NoStash", .{
        .initial_pos = .{ 50, 50 },
        .initial_size = .{ 300, 200 },
    });
    u.dockBuilderDockWindow("NoStash", leaf_id);
    // Manually clear the stash (simulating a window that was
    // docked from creation rather than dragged in).
    w.pre_dock_pos = null;
    w.pre_dock_size = null;
    // Simulate the leaf rect having been applied as the
    // window's effective size during render.
    w.size = .{ 800, 400 };

    ctx.input.mouse_pos = .{ 100, 50 };
    detachAndStartDrag(&ctx, w.id);

    // No stash → keeps current (leaf-sized) size as the new
    // floating size. Less ideal than the pre-dock-restore
    // case but doesn't crash.
    try expectEqual(@as(Vec2, .{ 800, 400 }), w.size);
}

test "5.5j: detachAndStartDrag is no-op for non-docked window" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    const w: *Window = findOrCreateWindow(&ctx, "Floater", .{
        .initial_pos = .{ 10, 20 },
        .initial_size = .{ 100, 80 },
    });
    detachAndStartDrag(&ctx, w.id);

    // Unchanged.
    try expectEqual(@as(?Id, null), w.dock_node_id);
    try expectEqual(@as(?Id, null), ctx.dock.dragging_window);
    try expectEqual(@as(Vec2, .{ 10, 20 }), w.pos);
}

test "5.5j: drag-reorder swaps tab ordering when cursor crosses a sibling slot" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    // Build a dockspace with 3 docked windows: A B C.
    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    ctx.dock.lookup(leaf_id).?.pos = .{ 0, 0 };
    ctx.dock.lookup(leaf_id).?.size = .{ 300, 200 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    _ = findOrCreateWindow(&ctx, "A", .{});
    _ = findOrCreateWindow(&ctx, "B", .{});
    _ = findOrCreateWindow(&ctx, "C", .{});
    u.dockBuilderDockWindow("A", leaf_id);
    u.dockBuilderDockWindow("B", leaf_id);
    u.dockBuilderDockWindow("C", leaf_id);

    // Pre-condition: order is A, B, C.
    const leaf: *DockNode.LeafData = &ctx.dock.lookup(leaf_id).?.leaf.?;
    try expectEqual(@as(usize, 3), leaf.window_ids.items.len);
    const a_wid: Id = leaf.window_ids.items[0];
    const b_wid: Id = leaf.window_ids.items[1];
    const c_wid: Id = leaf.window_ids.items[2];

    // Simulate: user is dragging tab A (idx 0). Cursor over
    // tab C's horizontal slot (idx 2), still inside the strip
    // vertically.
    ctx.active_id = hashStr(a_wid, "__tab_drag");
    ctx.active_id_press_screen = .{ 50, 10 };
    ctx.input.mouse_pos = .{ 250, 10 }; // tab_w = 100; slot 2 spans 200-300
    ctx.input.mouse_left_down = true;

    ctx.current_draw_list = &ctx.foreground_dl;
    renderDockLeafTabBars(&ctx, leaf_id);

    // After reorder: A should now be at idx 2.
    try expectEqual(@as(usize, 3), leaf.window_ids.items.len);
    try expectEqual(b_wid, leaf.window_ids.items[0]);
    try expectEqual(c_wid, leaf.window_ids.items[1]);
    try expectEqual(a_wid, leaf.window_ids.items[2]);
}

test "5.5j: drag-reorder no-op when cursor over the dragged tab's own slot" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    ctx.dock.lookup(leaf_id).?.pos = .{ 0, 0 };
    ctx.dock.lookup(leaf_id).?.size = .{ 300, 200 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    _ = findOrCreateWindow(&ctx, "A", .{});
    _ = findOrCreateWindow(&ctx, "B", .{});
    _ = findOrCreateWindow(&ctx, "C", .{});
    u.dockBuilderDockWindow("A", leaf_id);
    u.dockBuilderDockWindow("B", leaf_id);
    u.dockBuilderDockWindow("C", leaf_id);

    const leaf: *DockNode.LeafData = &ctx.dock.lookup(leaf_id).?.leaf.?;
    const b_wid: Id = leaf.window_ids.items[1];

    // Dragging B (idx 1); cursor over B's slot.
    ctx.active_id = hashStr(b_wid, "__tab_drag");
    ctx.active_id_press_screen = .{ 150, 10 };
    ctx.input.mouse_pos = .{ 155, 10 }; // tiny motion, still in B's slot
    ctx.input.mouse_left_down = true;

    ctx.current_draw_list = &ctx.foreground_dl;
    renderDockLeafTabBars(&ctx, leaf_id);

    // Order unchanged.
    try expectEqual(b_wid, leaf.window_ids.items[1]);
}

test "5.5j: drag pulled Y > DETACH_Y_THRESHOLD out of strip → detach" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    ctx.dock.lookup(leaf_id).?.pos = .{ 0, 0 };
    ctx.dock.lookup(leaf_id).?.size = .{ 300, 200 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    _ = findOrCreateWindow(&ctx, "A", .{});
    _ = findOrCreateWindow(&ctx, "B", .{});
    u.dockBuilderDockWindow("A", leaf_id);
    u.dockBuilderDockWindow("B", leaf_id);

    const leaf: *DockNode.LeafData = &ctx.dock.lookup(leaf_id).?.leaf.?;
    const a_wid: Id = leaf.window_ids.items[0];

    // Dragging A. Cursor at Y=80 - well below the title-bar
    // strip (height ≈ 26 px at default style.font_size=16).
    // Should detach.
    ctx.active_id = hashStr(a_wid, "__tab_drag");
    ctx.active_id_press_screen = .{ 50, 10 };
    ctx.input.mouse_pos = .{ 50, 80 };
    ctx.input.mouse_left_down = true;

    ctx.current_draw_list = &ctx.foreground_dl;
    renderDockLeafTabBars(&ctx, leaf_id);

    // A should be undocked now.
    const a_win: *Window = ctx.windows.get(a_wid).?;
    try expectEqual(@as(?Id, null), a_win.dock_node_id);
    try expectEqual(@as(?Id, a_wid), ctx.dock.dragging_window);
    // Leaf collapsed to just B.
    try expectEqual(@as(usize, 1), leaf.window_ids.items.len);
}

test "5.5j: small Y excursion within dead zone → no detach, no reorder" {
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();
    const u = Ui{ .ctx = &ctx };

    const leaf_id: Id = try ctx.dock.createNode(ctx.gpa, null, .{ .leaf = {} });
    ctx.dock.lookup(leaf_id).?.pos = .{ 0, 0 };
    ctx.dock.lookup(leaf_id).?.size = .{ 300, 200 };
    try ctx.dock.dockspaces_this_frame.append(ctx.gpa, leaf_id);

    _ = findOrCreateWindow(&ctx, "A", .{});
    _ = findOrCreateWindow(&ctx, "B", .{});
    u.dockBuilderDockWindow("A", leaf_id);
    u.dockBuilderDockWindow("B", leaf_id);

    const leaf: *DockNode.LeafData = &ctx.dock.lookup(leaf_id).?.leaf.?;
    const a_wid: Id = leaf.window_ids.items[0];

    // Cursor just below the strip's bottom edge - outside the
    // reorder slack but inside the detach dead zone.
    // Strip is at Y [0, ~26]; slack adds 4 px so reorder ends
    // at Y=30. Detach threshold is 16 px past strip bottom,
    // so detach starts at Y=42. Y=35 is dead zone.
    ctx.active_id = hashStr(a_wid, "__tab_drag");
    ctx.active_id_press_screen = .{ 50, 10 };
    ctx.input.mouse_pos = .{ 50, 35 };
    ctx.input.mouse_left_down = true;

    ctx.current_draw_list = &ctx.foreground_dl;
    renderDockLeafTabBars(&ctx, leaf_id);

    // A should still be docked; no reorder.
    const a_win: *Window = ctx.windows.get(a_wid).?;
    try expectEqual(@as(?Id, leaf_id), a_win.dock_node_id);
    try expectEqual(a_wid, leaf.window_ids.items[0]);
}

// ============================================================================
// Dock layer (inlined from former src/ui_dock.zig)
// ============================================================================
//

// What this section owns: the dock tree data model and the
// pure-CPU operations that mutate it. No drawing, no input
// handling — those live alongside other widget code earlier in
// this file (dockSpace widget, drag-to-dock targets, splitter
// drag, docked-window rendering all reside in the main widget
// section above).
//

// History: this content was its own file (`src/ui_dock.zig`) from
// the 5.5a-5.5g arc that built the docking feature. Inlined back
// here — keeping it separate cost ongoing navigation
// for thin benefit, since the dock layer and the widget layer
// touch each other constantly.
//

// The model, briefly:
//   - A `DockNode` is either a SPLIT (two children + axis +
//     ratio) or a LEAF (a list of window IDs + a "selected"
//     pointer for the tab bar). Never both, never neither.
//     Encoded as two optional fields where exactly one is set.
//   - A `DockContext` owns the node pool, keyed by `Id`. Nodes
//     reference children by ID (not pointer) so the pool can
//     reallocate the underlying HashMap without invalidating
//     references.
//   - Tree mutation goes through `splitNode`, `dockWindow`,
//     `undockWindow`, `removeNode`. Each maintains the
//     invariant that a SPLIT has two non-null child IDs that
//     exist in the pool; a LEAF has a possibly-empty window
//     list; and each non-root node has a parent_id resolving to
//     a SPLIT containing this node's id in its child_ids.
//

// What this section deliberately doesn't do (delegated to widget
// code above): drag-drop hit-testing, visual rendering, and
// persistence integration (which lives in `src/ui_persistence.zig`).
// The user-facing API is `dockBuilder*` exposed on the `Ui`
// handle earlier in this file.
// ============================================================================

// ============================================================================
// SECTION 1 - Enums + small types
// ============================================================================

/// Split orientation. A horizontal split divides space along the
/// horizontal axis, putting children side-by-side; a vertical split
/// stacks them top/bottom. Matches imgui's `ImGuiDir_*` mapping
/// for the two non-degenerate cases.
pub const SplitAxis = enum(u8) {
    /// Children sit left/right of each other; the boundary is a
    /// vertical line.
    horizontal,
    /// Children sit above/below each other; the boundary is a
    /// horizontal line.
    vertical,
};

/// Dock target direction for drag-drop. Center = tab into the
/// target leaf; the four edges create a new split with the dragged
/// window on that side. Used by `DockRequest.dock_as_split` and
/// the future drag-target zone visualization.
pub const Dir = enum(u8) {
    center,
    top,
    right,
    bottom,
    left,

    /// True for the four edge directions - useful for "split or
    /// tab" branches at call sites.
    pub fn isEdge(self: Dir) bool {
        return self != .center;
    }

    /// The split axis implied by an edge direction. Top/bottom
    /// → vertical (stacked). Left/right → horizontal (side-by-side).
    /// Center isn't an edge - calling `splitAxis(.center)` is a
    /// programmer error; we return horizontal as a fallback rather
    /// than panicking so misuse degrades to wrong-but-not-crashing.
    pub fn splitAxis(self: Dir) SplitAxis {
        return switch (self) {
            .top, .bottom => .vertical,
            .left, .right => .horizontal,
            .center => .horizontal,
        };
    }

    /// True when the dragged window should become the FIRST child
    /// (top of a vertical split, or left of a horizontal one).
    /// False for bottom/right. Determines child ordering in
    /// `splitNode`.
    pub fn isFirstChild(self: Dir) bool {
        return switch (self) {
            .top, .left => true,
            .bottom, .right => false,
            .center => true,
        };
    }
};

/// Default split ratio when one side has no size preference.
/// 0.5 = 50/50. Used by `splitNode` if `ratio` is undefined.
pub const default_split_ratio: f32 = 0.5;

// ============================================================================
// SECTION 2 - DockNode
// ============================================================================

/// A node in the dock tree. Lives in `DockContext.nodes`,
/// referenced everywhere else by `Id`.
/// Invariants:
///   - Exactly one of `split` and `leaf` is non-null. A SPLIT
///     node has children; a LEAF holds windows. Empty leaves are
///     allowed (the user closed all docked windows in this slot);
///     empty splits are not (a split with one or zero children
///     should be collapsed away by `collapse`).
///   - `parent_id == null` iff this node is the root of a
///     dockspace.
///   - For a SPLIT, both `child_ids` exist in `DockContext.nodes`
///     and their `parent_id` resolves back to this node.
pub const DockNode = struct {
    id: Id,
    parent_id: ?Id = null,

    /// Computed rect - top-left corner + outer dimensions in
    /// screen pixels. Refreshed each frame by `layout` based on
    /// the dockspace's rect and the cumulative split ratios.
    /// Holds the previous frame's value between layouts; widgets
    /// can read it.
    pos: Vec2 = .{ 0, 0 },
    size: Vec2 = .{ 0, 0 },

    /// SPLIT data, null for leaves.
    split: ?SplitData = null,
    /// LEAF data, null for splits.
    leaf: ?LeafData = null,

    /// per-node flags constraining behavior.
    /// Flat packed struct, no Shared/Local/Local-in-Windows tier
    /// system imgui has - JUSTIFIED simplification. See
    /// `DockNodeFlags` doc.
    flags: DockNodeFlags = .{},

    /// Per-node generation counter - incremented on every mutation
    /// (split, dock, undock, ratio change). Used by callers that
    /// cache derived state to detect "did anything change since
    /// last frame". Wrapping is fine; collisions across many
    /// thousands of frames don't break anything (the cache
    /// invalidates on the wrong frame at worst).
    generation: u32 = 0,

    pub const SplitData = struct {
        axis: SplitAxis,
        /// Fraction of available space allocated to the FIRST
        /// child (top or left). Clamped to `[min_ratio,
        /// 1 - min_ratio]` so neither child can collapse to zero.
        /// Used when neither child has a `size_ref` lock.
        ratio: f32 = default_split_ratio,
        /// Two children - first is top/left, second is bottom/right.
        /// Both must always be valid IDs in the pool.
        child_ids: [2]Id,
        /// optional pixel-locked sizes per
        /// child. `null` means "follow `ratio`"; non-null means
        /// "lock this child at this many pixels along the split
        /// axis". Exactly one element should be non-null at a
        /// time - locking both makes the layout over-constrained
        /// (current behavior: falls back to ratio if both are
        /// non-null). Lighter version of imgui's per-node
        /// `SizeRef[axis]` field.
        /// Pairs with `DockNodeFlags.is_central` for "fixed-width
        /// left panel + central area absorbs remainder" UX. See
        /// `dockBuilderSetSizeRef`.
        size_ref: [2]?f32 = .{ null, null },
    };

    pub const LeafData = struct {
        /// Windows currently docked into this leaf. Order is the
        /// tab order (left-to-right in the tab bar). Allocated
        /// through the context's gpa.
        window_ids: ArrayList(Id) = .empty,
        /// Which docked window is currently visible. Should equal
        /// one of the entries in `window_ids` whenever the list is
        /// non-empty. Zero when the leaf is empty.
        selected_window_id: Id = 0,

        pub fn deinit(self: *LeafData, gpa: Allocator) void {
            self.window_ids.deinit(gpa);
        }

        /// Linear scan for a window's index in this leaf. Returns
        /// null if not present. Tab counts in leaves are small
        /// (~3-10 in typical use) so linear is fine - same logic
        /// as `TabBarState.findTab`.
        pub fn indexOf(self: *const LeafData, window_id: Id) ?usize {
            for (self.window_ids.items, 0..) |id, i| {
                if (id == window_id) {
                    return i;
                }
            }
            return null;
        }
    };

    pub fn isSplit(self: *const DockNode) bool {
        return self.split != null;
    }
    pub fn isLeaf(self: *const DockNode) bool {
        return self.leaf != null;
    }
};

/// Minimum split ratio. Below this, the side becomes too small
/// to drag/click reliably. Splitter drag clamps to
/// `[min_ratio, 1 - min_ratio]`.
pub const min_ratio: f32 = 0.05;

// ============================================================================
// SECTION 3 - DockRequest
// ============================================================================

// ============================================================================
// SECTION 4 - DockContext
// ============================================================================

// ============================================================================
// SECTION 5 - Tree mutation primitives
// ============================================================================

/// Split `target_id` along `dir`, creating two new child leaves.
/// The original `target` (which must be a leaf) is turned into a
/// SPLIT; its windows move into the child that's on the OPPOSITE
/// side of `dir` (so dragging a window to the right of a leaf
/// puts the existing content on the left). Returns the two new
/// child IDs as `{a, b}` where `a` is the first/top/left child
/// and `b` is the second/bottom/right child.
/// `ratio` controls space allocated to the first child (top/left);
/// passing `default_split_ratio` gives a 50/50 split.
/// Errors:
///   - InvalidTarget if `target_id` doesn't refer to a leaf.
///   - OutOfMemory if node allocation fails.
pub const SplitError = error{InvalidTarget} || Allocator.Error;

pub fn splitNode(
    ctx: *DockContext,
    gpa: Allocator,
    target_id: Id,
    dir: Dir,
    ratio: f32,
) SplitError!SplitResult {
    if (dir == .center) {
        return error.InvalidTarget;
    }
    const target: *DockNode = ctx.lookup(target_id) orelse return error.InvalidTarget;
    if (!target.isLeaf()) {
        return error.InvalidTarget;
    }

    // Capture the leaf's windows; they'll go into one of the new
    // children (the one OPPOSITE to `dir`).
    const existing: DockNode.LeafData = target.leaf.?;
    target.leaf = null; // we're taking ownership of `existing`'s list

    const child_a: Id = try ctx.createLeaf(gpa, target_id);
    const child_b: Id = try ctx.createLeaf(gpa, target_id);

    // Decide which child gets the existing windows. Convention:
    // dragging to.top puts the new content ABOVE existing
    // existing windows go to child_b (bottom). Symmetrically for
    // .bottom/.left/.right. See `Dir.isFirstChild`.
    const existing_goes_to_a: bool = !dir.isFirstChild();
    const recipient_id: Id = if (existing_goes_to_a) child_a else child_b;
    if (ctx.lookup(recipient_id)) |recip| {
        recip.leaf.?.window_ids = existing.window_ids;
        recip.leaf.?.selected_window_id = existing.selected_window_id;
        // transfer leaf-affinity flags from the
        // original (now-promoted-to-split) target to the
        // inheritor child. The "fresh" child keeps defaults.
        // Mirrors imgui:20204 (DockNodeTreeSplit) using
        // `LocalFlagsTransferMask_` - but we transfer ALL
        // matching bits to the inheritor and clear them on the
        // parent (which becomes a split node and shouldn't carry
        // leaf-only flags anymore).
        recip.flags = target.flags.intersect(DockNodeFlags.transfer_mask);
    }
    // Parent (now a split node) clears the transferable flags;
    // keeps tree-structural ones (is_dockspace, passthru_central).
    target.flags = target.flags.except(DockNodeFlags.transfer_mask);

    const clamped_ratio: f32 = clamp(ratio, min_ratio, 1.0 - min_ratio);
    target.split = .{
        .axis = dir.splitAxis(),
        .ratio = clamped_ratio,
        .child_ids = .{ child_a, child_b },
    };
    target.generation +%= 1;

    return .{ .a = child_a, .b = child_b };
}

// ============================================================================
// SECTION 6 - Layout
// ============================================================================

/// Get the final rect of `node_id` after the last layout pass.
/// Returns a zero-sized rect at the origin if the id doesn't
/// exist. Read-only helper for the future docked-window
/// rendering path.
pub fn nodeRect(ctx: *const DockContext, node_id: Id) Rectangle {
    const node: *DockNode = ctx.lookup(node_id) orelse return .{ .x = 0, .y = 0, .width = 0, .height = 0 };
    return .{
        .x = node.pos[0],
        .y = node.pos[1],
        .width = node.size[0],
        .height = node.size[1],
    };
}

// ============================================================================
// SECTION 7 - Request processing
// ============================================================================

/// Apply all queued requests in submission order. Called at a
/// well-defined point in the frame (before window submission, after
/// drag-drop releases have been observed). Drains
/// `pending_requests`.
pub fn processRequests(ctx: *DockContext, gpa: Allocator) Allocator.Error!void {
    // Drain into a local snapshot so requests enqueued during
    // processing (rare but possible) wait until next frame.
    const reqs = try gpa.dupe(DockRequest, ctx.pending_requests.items);
    defer gpa.free(reqs);
    ctx.pending_requests.clearRetainingCapacity();

    for (reqs) |req| {
        switch (req) {
            .dock_as_tab => |r| {
                _ = dockWindowAsTab(ctx, gpa, r.target_node, r.source_window) catch {};
            },
            .dock_as_split => |r| {
                if (r.dir == .center) {
                    _ = dockWindowAsTab(ctx, gpa, r.target_node, r.source_window) catch {};
                } else {
                    const result: SplitResult = splitNode(
                        ctx,
                        gpa,
                        r.target_node,
                        r.dir,
                        default_split_ratio,
                    ) catch continue;
                    const target_child: Id = if (r.dir.isFirstChild()) result.a else result.b;
                    _ = dockWindowAsTab(ctx, gpa, target_child, r.source_window) catch {};
                }
            },
            .undock => |r| {
                _ = undockWindow(ctx, gpa, r.source_window);
            },
            .split => |r| {
                _ = splitNode(ctx, gpa, r.target_node, r.dir, r.ratio) catch continue;
            },
        }
    }
}

// ============================================================================
// SECTION 8 - Tests
// ============================================================================

const testing = std.testing;

test "createLeaf + lookup: roundtrip" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const id: Id = try ctx.createLeaf(gpa, null);
    const node: *DockNode = ctx.lookup(id) orelse return error.LookupFailed;
    try testing.expect(node.isLeaf());
    try testing.expect(!node.isSplit());
    try testing.expectEqual(@as(?Id, null), node.parent_id);
}

test "dockWindowAsTab: adds window, selects it" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, leaf, 100);
    try dockWindowAsTab(&ctx, gpa, leaf, 200);

    const node: *DockNode = ctx.lookup(leaf).?;
    try testing.expectEqual(@as(usize, 2), node.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(Id, 200), node.leaf.?.selected_window_id);
}

test "dockWindowAsTab: duplicate window promotes to selected, doesn't duplicate" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, leaf, 100);
    try dockWindowAsTab(&ctx, gpa, leaf, 200);
    try dockWindowAsTab(&ctx, gpa, leaf, 100); // re-add first

    const node: *DockNode = ctx.lookup(leaf).?;
    try testing.expectEqual(@as(usize, 2), node.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(Id, 100), node.leaf.?.selected_window_id);
}

test "undockWindow: removes window, picks new selection" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, leaf, 100);
    try dockWindowAsTab(&ctx, gpa, leaf, 200);
    try dockWindowAsTab(&ctx, gpa, leaf, 300);

    try testing.expect(undockWindow(&ctx, gpa, 200));
    const node: *DockNode = ctx.lookup(leaf).?;
    try testing.expectEqual(@as(usize, 2), node.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(Id, 100), node.leaf.?.window_ids.items[0]);
    try testing.expectEqual(@as(Id, 300), node.leaf.?.window_ids.items[1]);
}

test "undockWindow: empty leaf at root stays present" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, leaf, 100);
    try testing.expect(undockWindow(&ctx, gpa, 100));

    const node: *DockNode = ctx.lookup(leaf).?;
    try testing.expectEqual(@as(usize, 0), node.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(Id, 0), node.leaf.?.selected_window_id);
}

test "splitNode: leaf becomes split, existing windows go to opposite child" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, root, 100);
    try dockWindowAsTab(&ctx, gpa, root, 200);

    const result: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.5);
    const root_node: *DockNode = ctx.lookup(root).?;
    try testing.expect(root_node.isSplit());
    try testing.expectEqual(SplitAxis.horizontal, root_node.split.?.axis);

    // .right dir: existing windows are NOT in the "first" child;
    // they stayed in the original (now-left, child_a).
    const left: *DockNode = ctx.lookup(result.a).?;
    const right: *DockNode = ctx.lookup(result.b).?;
    try testing.expectEqual(@as(usize, 2), left.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(usize, 0), right.leaf.?.window_ids.items.len);
}

test "splitNode: .left places existing windows in child_b" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, root, 1);

    const result: SplitResult = try splitNode(&ctx, gpa, root, .left, 0.3);
    const left: *DockNode = ctx.lookup(result.a).?;
    const right: *DockNode = ctx.lookup(result.b).?;
    try testing.expectEqual(@as(usize, 0), left.leaf.?.window_ids.items.len);
    try testing.expectEqual(@as(usize, 1), right.leaf.?.window_ids.items.len);
}

test "splitNode: ratio clamps to [min_ratio, 1 - min_ratio]" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    _ = try splitNode(&ctx, gpa, root, .right, 0.001);
    try testing.expectApproxEqAbs(min_ratio, ctx.lookup(root).?.split.?.ratio, 1e-6);

    var ctx2: DockContext = .{};
    defer ctx2.deinit(gpa);
    const root2: Id = try ctx2.createLeaf(gpa, null);
    _ = try splitNode(&ctx2, gpa, root2, .right, 0.999);
    try testing.expectApproxEqAbs(1.0 - min_ratio, ctx2.lookup(root2).?.split.?.ratio, 1e-6);
}

test "splitNode: rejects center direction" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);
    const root: Id = try ctx.createLeaf(gpa, null);
    try testing.expectError(error.InvalidTarget, splitNode(&ctx, gpa, root, .center, 0.5));
}

test "undockWindow: empty leaf with parent split collapses, sibling absorbs parent" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, root, 1);
    try dockWindowAsTab(&ctx, gpa, root, 2);

    const split_result: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.5);
    try dockWindowAsTab(&ctx, gpa, split_result.b, 3); // put window 3 on right side

    // Now root is a SPLIT, child_a holds windows 1,2, child_b holds 3.
    // Undock all from child_b - leaf goes empty, parent collapses,
    // root becomes a leaf again containing windows 1 and 2.
    _ = undockWindow(&ctx, gpa, 3);

    const root_node: *DockNode = ctx.lookup(root).?;
    try testing.expect(root_node.isLeaf());
    try testing.expectEqual(@as(usize, 2), root_node.leaf.?.window_ids.items.len);
    // The two child nodes should be freed.
    try testing.expectEqual(@as(?*DockNode, null), ctx.lookup(split_result.a));
    try testing.expectEqual(@as(?*DockNode, null), ctx.lookup(split_result.b));
}

test "9.1: collapse carries the surviving sibling's flags up" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, root, 1);
    const sr: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.3);
    // .right keeps existing window 1 in child_a; put window 2 in child_b.
    try dockWindowAsTab(&ctx, gpa, sr.b, 2);
    // The survivor (child_b) is the central, non-resizable node.
    ctx.lookup(sr.b).?.flags.is_central = true;
    ctx.lookup(sr.b).?.flags.no_resize = true;

    // Undock window 1 -> child_a empties -> collapses -> child_b absorbs root.
    _ = undockWindow(&ctx, gpa, 1);

    const root_node: *DockNode = ctx.lookup(root).?;
    try testing.expect(root_node.isLeaf());
    try testing.expectEqual(@as(usize, 1), root_node.leaf.?.window_ids.items.len);
    // The #9.1 fix: the survivor's leaf-affinity flags must carry up. Before the
    // fix these were silently dropped (parent kept its own split-node flags).
    try testing.expect(root_node.flags.is_central);
    try testing.expect(root_node.flags.no_resize);
}

test "layoutSubtree: leaf gets root's full rect" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    layoutSubtree(&ctx, leaf, .{ 10, 20 }, .{ 400, 300 });

    const rect: Rectangle = nodeRect(&ctx, leaf);
    try testing.expectEqual(@as(f32, 10), rect.x);
    try testing.expectEqual(@as(f32, 20), rect.y);
    try testing.expectEqual(@as(f32, 400), rect.width);
    try testing.expectEqual(@as(f32, 300), rect.height);
}

test "layoutSubtree: horizontal split divides width by ratio with gap" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    const split_result: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.3);
    layoutSubtree(&ctx, root, .{ 0, 0 }, .{ 1000, 500 });

    // splitter_size (4 px) is reserved between children; the
    // remaining 996 px is divided 30/70.
    const avail: f32 = 1000 - splitter_size;
    const left: Rectangle = nodeRect(&ctx, split_result.a);
    const right: Rectangle = nodeRect(&ctx, split_result.b);
    try testing.expectApproxEqAbs(avail * 0.3, left.width, 0.001);
    try testing.expectApproxEqAbs(avail * 0.7, right.width, 0.001);
    try testing.expectApproxEqAbs(@as(f32, 0), left.x, 0.001);
    try testing.expectApproxEqAbs(left.width + splitter_size, right.x, 0.001);
    try testing.expectApproxEqAbs(@as(f32, 500), left.height, 0.001);
    try testing.expectApproxEqAbs(@as(f32, 500), right.height, 0.001);
}

test "layoutSubtree: vertical split divides height by ratio with gap" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    const split_result: SplitResult = try splitNode(&ctx, gpa, root, .bottom, 0.4);
    layoutSubtree(&ctx, root, .{ 0, 0 }, .{ 800, 600 });

    const avail: f32 = 600 - splitter_size;
    const top: Rectangle = nodeRect(&ctx, split_result.a);
    const bottom: Rectangle = nodeRect(&ctx, split_result.b);
    try testing.expectApproxEqAbs(avail * 0.4, top.height, 0.001);
    try testing.expectApproxEqAbs(avail * 0.6, bottom.height, 0.001);
    try testing.expectApproxEqAbs(top.height + splitter_size, bottom.y, 0.001);
    try testing.expectApproxEqAbs(@as(f32, 800), top.width, 0.001);
    try testing.expectApproxEqAbs(@as(f32, 800), bottom.width, 0.001);
}

test "layoutSubtree: nested splits recurse correctly" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    // Build:
    //   root (vertical split, ratio 0.5)
    //     ├ a (top half)
    //     └ b (bottom half)
    //         └ split into [c (left), d (right)] horizontally at 0.5
    const root: Id = try ctx.createLeaf(gpa, null);
    const r1: SplitResult = try splitNode(&ctx, gpa, root, .bottom, 0.5);
    const r2: SplitResult = try splitNode(&ctx, gpa, r1.b, .right, 0.5);

    layoutSubtree(&ctx, root, .{ 0, 0 }, .{ 1000, 1000 });

    // Each split eats splitter_size; outer split halves
    // (1000 - 4)/2 = 498. Inner split inside the bottom 498:
    // halves (1000 - 4)/2 = 498 horizontally.
    const outer_half: f32 = (1000 - splitter_size) * 0.5;
    const inner_half: f32 = (1000 - splitter_size) * 0.5;
    try testing.expectApproxEqAbs(outer_half, nodeRect(&ctx, r1.a).height, 0.001);
    try testing.expectApproxEqAbs(inner_half, nodeRect(&ctx, r2.a).width, 0.001);
    try testing.expectApproxEqAbs(outer_half, nodeRect(&ctx, r2.a).height, 0.001);
    try testing.expectApproxEqAbs(inner_half, nodeRect(&ctx, r2.b).width, 0.001);
}

test "processRequests: applies dock_as_tab + dock_as_split" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try ctx.pending_requests.append(gpa, .{ .dock_as_tab = .{ .target_node = root, .source_window = 10 } });
    try ctx.pending_requests.append(gpa, .{ .dock_as_tab = .{ .target_node = root, .source_window = 20 } });
    try processRequests(&ctx, gpa);

    try testing.expectEqual(@as(usize, 0), ctx.pending_requests.items.len);
    const node: *DockNode = ctx.lookup(root).?;
    try testing.expectEqual(@as(usize, 2), node.leaf.?.window_ids.items.len);
}

test "processRequests: dock_as_split splits and puts source in correct child" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    try dockWindowAsTab(&ctx, gpa, root, 100); // existing window

    try ctx.pending_requests.append(gpa, .{
        .dock_as_split = .{ .target_node = root, .source_window = 200, .dir = .right },
    });
    try processRequests(&ctx, gpa);

    // After: root is now a split. Left child has window 100;
    // right child has window 200.
    const root_node: *DockNode = ctx.lookup(root).?;
    try testing.expect(root_node.isSplit());
    const left_id: Id = root_node.split.?.child_ids[0];
    const right_id: Id = root_node.split.?.child_ids[1];
    try testing.expectEqual(@as(?usize, 0), ctx.lookup(left_id).?.leaf.?.indexOf(100));
    try testing.expectEqual(@as(?usize, 0), ctx.lookup(right_id).?.leaf.?.indexOf(200));
}

test "removeNode: frees entire subtree" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    const split: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.5);
    try dockWindowAsTab(&ctx, gpa, split.a, 1);
    try dockWindowAsTab(&ctx, gpa, split.b, 2);

    // Pool now has 3 nodes (root, split.a, split.b).
    try testing.expectEqual(@as(usize, 3), ctx.nodes.count());

    removeNode(&ctx, gpa, root);
    try testing.expectEqual(@as(usize, 0), ctx.nodes.count());
}

test "findLeafFor: locates the leaf containing a window" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const root: Id = try ctx.createLeaf(gpa, null);
    const split: SplitResult = try splitNode(&ctx, gpa, root, .right, 0.5);
    try dockWindowAsTab(&ctx, gpa, split.a, 100);
    try dockWindowAsTab(&ctx, gpa, split.b, 200);

    try testing.expectEqual(split.a, ctx.findLeafFor(100).?.id);
    try testing.expectEqual(split.b, ctx.findLeafFor(200).?.id);
    try testing.expectEqual(@as(?*DockNode, null), ctx.findLeafFor(999));
}

test "Dir.splitAxis + Dir.isFirstChild semantics" {
    try testing.expectEqual(SplitAxis.vertical, Dir.splitAxis(.top));
    try testing.expectEqual(SplitAxis.vertical, Dir.splitAxis(.bottom));
    try testing.expectEqual(SplitAxis.horizontal, Dir.splitAxis(.left));
    try testing.expectEqual(SplitAxis.horizontal, Dir.splitAxis(.right));
    try testing.expectEqual(true, Dir.isFirstChild(.top));
    try testing.expectEqual(true, Dir.isFirstChild(.left));
    try testing.expectEqual(false, Dir.isFirstChild(.bottom));
    try testing.expectEqual(false, Dir.isFirstChild(.right));
    try testing.expectEqual(true, Dir.isEdge(.top));
    try testing.expectEqual(false, Dir.isEdge(.center));
}

test "generation: bumps on mutation" {
    const gpa: Allocator = testing.allocator;
    var ctx: DockContext = .{};
    defer ctx.deinit(gpa);

    const leaf: Id = try ctx.createLeaf(gpa, null);
    const node: *DockNode = ctx.lookup(leaf).?;
    const g0: u32 = node.generation;

    try dockWindowAsTab(&ctx, gpa, leaf, 1);
    try testing.expect(node.generation != g0);

    const g1: u32 = node.generation;
    try dockWindowAsTab(&ctx, gpa, leaf, 1); // dup - promotes selection but no new tab
    // Generation only bumps on tree structure mutations, not on
    // re-selection of an existing tab. This is fine; cachers
    // re-check selection separately.
    try testing.expectEqual(g1, node.generation);
}

// ============================================================================
// Persistence layer (inlined from former src/ui_persistence.zig)
// ============================================================================
//

// What this section owns: serialization + deserialization of
// UiContext state to/from zon text. The schema-versioned shape
// (`PersistedState`) decouples the in-memory representation from
// the on-the-wire format; missing optional fields take their
// defaults so additive schema changes don't bump the version.
//

// History: this content was its own file (`src/ui_persistence.zig`)
// from the persistence arc. Inlined back here — same
// reasoning as ui_dock: the dense cross-references to
// Window, DockContext, Style, TabBarState made the file boundary
// pure navigation cost.
//

// Responsibilities:
//   - `PersistedWindow` / `PersistedState` — on-the-wire shape
//   - `serialize(gpa, ctx)` — walk windows, build snapshot, zon-stringify
//   - `apply(gpa, ctx, text)` — parse zon, populate
//     `ctx.pending_persistence` for next-frame consumption
//   - `takeFor(ctx, title)` — pop a pending entry on first
//     `openWindow` for that name
//

// Storage layer (the JS / localStorage half) is NOT in this section.
// This content is pure CPU + std.zon, fully host-testable. The JS
// externs that move bytes between zimr and `localStorage` live
// alongside other runtime input/clipboard externs.
//

// Schema versioning policy:
//   - Every payload starts with `.version = N`
//   - On non-additive change (rename, remove, semantic shift) bump
//     `current_version`; mismatched payloads are silently discarded
//   - Additive changes (new optional fields with defaults) need no
//     version bump — zon parser skips unknown keys with
//     `.ignore_unknown_fields = true` and defaults fill the rest
// ============================================================================

// ============================================================================
// SECTION 1 - Schema
// ============================================================================

inline fn colorToBytes(c: Color) [4]u8 {
    return .{ c.r, c.g, c.b, c.a };
}

inline fn bytesToColor(b: [4]u8) Color {
    return .{ .r = b[0], .g = b[1], .b = b[2], .a = b[3] };
}

/// On-the-wire shape for `Style`. Mirrors every Color + numeric
/// field on `Style` (skips `font: ?*const Font` — pointers can't
/// round-trip a localStorage save). Optional on `PersistedState`;
/// apps that don't theme can leave it null and save no bytes.
///
/// Each `Color` is stored as `[4]u8` (r, g, b, a) for compact.zon
/// output — `Color = struct { r: u8, g: u8, b: u8, a: u8 }` would
/// also work but produces a much chattier serialization.
///
/// Versioning: when a new color slot is added to `Style`, give it
/// a default in `PersistedStyle.fromStyle` so old saves load
/// cleanly. The opposite direction (loading a NEW save into OLD
/// code) is handled by std.zon ignoring unknown fields when
/// `.allow_unknown = true` is passed to `parseFromSlice`.
pub const PersistedStyle = struct {
    // ---- Colors (27 slots — order matches Style decl)
    text: [4]u8 = .{ 230, 230, 230, 255 },
    text_disabled: [4]u8 = .{ 128, 128, 128, 255 },
    window_bg: [4]u8 = .{ 15, 15, 15, 240 },
    border: [4]u8 = .{ 110, 110, 128, 128 },
    title_bg: [4]u8 = .{ 10, 10, 10, 255 },
    title_bg_active: [4]u8 = .{ 41, 74, 122, 255 },
    button: [4]u8 = .{ 66, 150, 250, 102 },
    button_hovered: [4]u8 = .{ 66, 150, 250, 255 },
    button_active: [4]u8 = .{ 15, 135, 250, 255 },
    frame_bg: [4]u8 = .{ 41, 74, 122, 138 },
    frame_bg_hovered: [4]u8 = .{ 66, 150, 250, 102 },
    frame_bg_active: [4]u8 = .{ 66, 150, 250, 171 },
    scrollbar_bg: [4]u8 = .{ 5, 5, 5, 135 },
    scrollbar_grab: [4]u8 = .{ 79, 79, 79, 255 },
    scrollbar_grab_hovered: [4]u8 = .{ 105, 105, 105, 255 },
    scrollbar_grab_active: [4]u8 = .{ 130, 130, 130, 255 },
    popup_bg: [4]u8 = .{ 20, 20, 20, 240 },
    modal_dim_bg: [4]u8 = .{ 35, 40, 50, 88 },
    menu_bar_bg: [4]u8 = .{ 30, 30, 35, 255 },
    menu_hovered: [4]u8 = .{ 66, 150, 250, 180 },
    tab: [4]u8 = .{ 46, 89, 148, 219 },
    tab_hovered: [4]u8 = .{ 66, 150, 250, 204 },
    tab_active: [4]u8 = .{ 51, 105, 173, 255 },
    tab_separator: [4]u8 = .{ 110, 110, 130, 255 },
    table_row_bg: [4]u8 = .{ 0, 0, 0, 0 },
    table_row_bg_alt: [4]u8 = .{ 255, 255, 255, 15 },
    text_link: [4]u8 = .{ 102, 159, 218, 255 },
    text_link_hovered: [4]u8 = .{ 140, 195, 240, 255 },
    text_link_underline: [4]u8 = .{ 80, 130, 180, 255 },

    // ---- Numeric (Vec2 written as [2]f32; scalars as their native type)
    window_padding: [2]f32 = .{ 8, 8 },
    frame_padding: [2]f32 = .{ 4, 3 },
    item_spacing: [2]f32 = .{ 8, 4 },
    title_bar_height: f32 = 22,
    menu_bar_height: f32 = 22,
    disabled_alpha: f32 = 0.6,
    font_size: f32 = 10,
    font_spacing: f32 = 0,
    line_spacing: i32 = 2,

    /// Capture a runtime `Style` into a serialization-ready form.
    /// Drops the `font` pointer (can't round-trip).
    pub fn fromStyle(s: *const Style) PersistedStyle {
        return .{
            .text = colorToBytes(s.text),
            .text_disabled = colorToBytes(s.text_disabled),
            .window_bg = colorToBytes(s.window_bg),
            .border = colorToBytes(s.border),
            .title_bg = colorToBytes(s.title_bg),
            .title_bg_active = colorToBytes(s.title_bg_active),
            .button = colorToBytes(s.button),
            .button_hovered = colorToBytes(s.button_hovered),
            .button_active = colorToBytes(s.button_active),
            .frame_bg = colorToBytes(s.frame_bg),
            .frame_bg_hovered = colorToBytes(s.frame_bg_hovered),
            .frame_bg_active = colorToBytes(s.frame_bg_active),
            .scrollbar_bg = colorToBytes(s.scrollbar_bg),
            .scrollbar_grab = colorToBytes(s.scrollbar_grab),
            .scrollbar_grab_hovered = colorToBytes(s.scrollbar_grab_hovered),
            .scrollbar_grab_active = colorToBytes(s.scrollbar_grab_active),
            .popup_bg = colorToBytes(s.popup_bg),
            .modal_dim_bg = colorToBytes(s.modal_dim_bg),
            .menu_bar_bg = colorToBytes(s.menu_bar_bg),
            .menu_hovered = colorToBytes(s.menu_hovered),
            .tab = colorToBytes(s.tab),
            .tab_hovered = colorToBytes(s.tab_hovered),
            .tab_active = colorToBytes(s.tab_active),
            .tab_separator = colorToBytes(s.tab_separator),
            .table_row_bg = colorToBytes(s.table_row_bg),
            .table_row_bg_alt = colorToBytes(s.table_row_bg_alt),
            .text_link = colorToBytes(s.text_link),
            .text_link_hovered = colorToBytes(s.text_link_hovered),
            .text_link_underline = colorToBytes(s.text_link_underline),
            .window_padding = .{ s.window_padding[0], s.window_padding[1] },
            .frame_padding = .{ s.frame_padding[0], s.frame_padding[1] },
            .item_spacing = .{ s.item_spacing[0], s.item_spacing[1] },
            .title_bar_height = s.title_bar_height,
            .menu_bar_height = s.menu_bar_height,
            .disabled_alpha = s.disabled_alpha,
            .font_size = s.font_size,
            .font_spacing = s.font_spacing,
            .line_spacing = s.line_spacing,
        };
    }

    /// Apply this persisted form back onto `target`. Preserves the
    /// caller's `font: ?*const Font` — the pointer cannot round-trip,
    /// so the caller's binding wins.
    pub fn applyTo(self: PersistedStyle, target: *Style) void {
        target.text = bytesToColor(self.text);
        target.text_disabled = bytesToColor(self.text_disabled);
        target.window_bg = bytesToColor(self.window_bg);
        target.border = bytesToColor(self.border);
        target.title_bg = bytesToColor(self.title_bg);
        target.title_bg_active = bytesToColor(self.title_bg_active);
        target.button = bytesToColor(self.button);
        target.button_hovered = bytesToColor(self.button_hovered);
        target.button_active = bytesToColor(self.button_active);
        target.frame_bg = bytesToColor(self.frame_bg);
        target.frame_bg_hovered = bytesToColor(self.frame_bg_hovered);
        target.frame_bg_active = bytesToColor(self.frame_bg_active);
        target.scrollbar_bg = bytesToColor(self.scrollbar_bg);
        target.scrollbar_grab = bytesToColor(self.scrollbar_grab);
        target.scrollbar_grab_hovered = bytesToColor(self.scrollbar_grab_hovered);
        target.scrollbar_grab_active = bytesToColor(self.scrollbar_grab_active);
        target.popup_bg = bytesToColor(self.popup_bg);
        target.modal_dim_bg = bytesToColor(self.modal_dim_bg);
        target.menu_bar_bg = bytesToColor(self.menu_bar_bg);
        target.menu_hovered = bytesToColor(self.menu_hovered);
        target.tab = bytesToColor(self.tab);
        target.tab_hovered = bytesToColor(self.tab_hovered);
        target.tab_active = bytesToColor(self.tab_active);
        target.tab_separator = bytesToColor(self.tab_separator);
        target.table_row_bg = bytesToColor(self.table_row_bg);
        target.table_row_bg_alt = bytesToColor(self.table_row_bg_alt);
        target.text_link = bytesToColor(self.text_link);
        target.text_link_hovered = bytesToColor(self.text_link_hovered);
        target.text_link_underline = bytesToColor(self.text_link_underline);
        target.window_padding = .{ self.window_padding[0], self.window_padding[1] };
        target.frame_padding = .{ self.frame_padding[0], self.frame_padding[1] };
        target.item_spacing = .{ self.item_spacing[0], self.item_spacing[1] };
        target.title_bar_height = self.title_bar_height;
        target.menu_bar_height = self.menu_bar_height;
        target.disabled_alpha = self.disabled_alpha;
        target.font_size = self.font_size;
        target.font_spacing = self.font_spacing;
        target.line_spacing = self.line_spacing;
        // target.font: caller-owned pointer; deliberately not touched.
    }
};

/// True when every field of `s` matches `Style.dark_default`.
/// Used by `serialize` to skip emitting a `PersistedStyle` block
/// for apps that haven't themed. Compares the Color + numeric
/// fields only; `font` is excluded (pointers compare by address
/// which is unstable across rebuilds).
fn styleIsDefault(s: *const Style) bool {
    const d: Style = Style.dark_default;
    return meta.eql(s.text, d.text) and
        meta.eql(s.text_disabled, d.text_disabled) and
        meta.eql(s.window_bg, d.window_bg) and
        meta.eql(s.border, d.border) and
        meta.eql(s.title_bg, d.title_bg) and
        meta.eql(s.title_bg_active, d.title_bg_active) and
        meta.eql(s.button, d.button) and
        meta.eql(s.button_hovered, d.button_hovered) and
        meta.eql(s.button_active, d.button_active) and
        meta.eql(s.frame_bg, d.frame_bg) and
        meta.eql(s.frame_bg_hovered, d.frame_bg_hovered) and
        meta.eql(s.frame_bg_active, d.frame_bg_active) and
        meta.eql(s.scrollbar_bg, d.scrollbar_bg) and
        meta.eql(s.scrollbar_grab, d.scrollbar_grab) and
        meta.eql(s.scrollbar_grab_hovered, d.scrollbar_grab_hovered) and
        meta.eql(s.scrollbar_grab_active, d.scrollbar_grab_active) and
        meta.eql(s.popup_bg, d.popup_bg) and
        meta.eql(s.modal_dim_bg, d.modal_dim_bg) and
        meta.eql(s.menu_bar_bg, d.menu_bar_bg) and
        meta.eql(s.menu_hovered, d.menu_hovered) and
        meta.eql(s.tab, d.tab) and
        meta.eql(s.tab_hovered, d.tab_hovered) and
        meta.eql(s.tab_active, d.tab_active) and
        meta.eql(s.tab_separator, d.tab_separator) and
        meta.eql(s.table_row_bg, d.table_row_bg) and
        meta.eql(s.table_row_bg_alt, d.table_row_bg_alt) and
        meta.eql(s.text_link, d.text_link) and
        meta.eql(s.text_link_hovered, d.text_link_hovered) and
        meta.eql(s.text_link_underline, d.text_link_underline) and
        meta.eql(s.window_padding, d.window_padding) and
        meta.eql(s.frame_padding, d.frame_padding) and
        meta.eql(s.item_spacing, d.item_spacing) and
        s.title_bar_height == d.title_bar_height and
        s.menu_bar_height == d.menu_bar_height and
        s.disabled_alpha == d.disabled_alpha and
        s.font_size == d.font_size and
        s.font_spacing == d.font_spacing and
        s.line_spacing == d.line_spacing;
}

/// The full persisted payload - a versioned envelope around an
/// array of `PersistedWindow`. Sized to fit comfortably in
/// localStorage (a few KB even for dozens of windows).
/// One persistable type's payload. `type_name` is `@typeName(T)`
/// from the putState call site, captured at first-persist time.
/// `payload` is a pre-stringified zon array of `{id, value}`
/// tuples — pre-stringified because each T is type-erased at
/// this layer and we can't put heterogeneous values in one slice.
/// The slot's `deserialize_fn` knows how to re-parse the array
/// back into the typed inner map.
pub const PersistedExtEntry = struct {
    type_name: []const u8 = "",
    payload: []const u8 = "",
};

pub const PersistedState = struct {
    version: u32 = current_version,
    windows: []const PersistedWindow = &.{},
    /// dock tree state. Empty for apps
    /// that don't use docking. Restored on load by walking
    /// the array and re-creating nodes verbatim - see
    /// `tryRestoreDockTree` in `apply`.
    dock_nodes: []const PersistedDockNode = &.{},
    /// User-themed style. Null = "use the in-memory default,
    /// don't override on load." Apps that mutate `ctx.style`
    /// AND set `persistence_key` get their colors saved alongside
    /// window layout; apps that leave `style` at `dark_default`
    /// can leave this null in the payload to save ~600 bytes.
    /// Set by `serialize` when the in-memory style differs from
    /// `Style.dark_default`.
    style: ?PersistedStyle = null,
    /// Per-typed-extension-state slots opted into persistence via
    /// `getOrPutState(T, id,.{.persist = true })`. Empty for
    /// apps that don't use persistable ext state.
    ext_state: []const PersistedExtEntry = &.{},
};

// ============================================================================
// SECTION 2 - Serialization
// ============================================================================

/// Collect tab-bar selections for the window with `window_name`.
/// Caller owns the returned slice (free with `gpa.free`). The
/// returned PersistedTabBar entries borrow into `ctx.tab_bar_state`
/// (str_id and label), valid until the next zon serialize pass.
fn collectTabBarsFor(
    gpa: Allocator,
    ctx: *const UiContext,
    window_name: []const u8,
) Allocator.Error![]PersistedTabBar {
    var out: ArrayList(PersistedTabBar) = .empty;
    errdefer out.deinit(gpa);

    var it: std.AutoHashMapUnmanaged(Id, TabBarState).Iterator = ctx.tab_bar_state.iterator();
    while (it.next()) |entry| {
        const state: *TabBarState = entry.value_ptr;
        // Match by parent window name. Bars whose parent doesn't
        // match (or whose name slot is empty because openTabBar
        // never populated it - shouldn't happen but defensive)
        // are skipped.
        const pname: []const u8 = state.parentWindowName();
        if (pname.len == 0) {
            continue;
        }
        if (!eql(u8, pname, window_name)) {
            continue;
        }
        const sid: []const u8 = state.strId();
        if (sid.len == 0) {
            continue;
        }

        // Find the currently-selected tab's label. If selected_id
        // is 0 (no selection persisted) or doesn't match any tab,
        // emit an empty label - the restore path treats this as
        // "no preference, use first-tab-auto-select".
        // CRITICAL: iterate by pointer. Capturing `t` by value
        // copies the entire TabState (including its inline
        // `label_buf` array) onto the loop stack frame; slicing
        // `t.label_buf[..]` then aliases that stack copy, which
        // dies when the loop iteration ends. Subsequent zon
        // serialize reads dangling memory.
        var label: []const u8 = "";
        for (state.tabs.items) |*t| {
            if (t.id == state.selected_id) {
                label = t.label_buf[0..t.label_len];
                break;
            }
        }
        try out.append(gpa, .{
            .str_id = sid,
            .selected_tab_label = label,
        });
    }
    return try out.toOwnedSlice(gpa);
}

/// Walk `ctx.windows`, build a `PersistedState`, zon-stringify it.
/// Caller owns the returned bytes (free with `gpa.free`).
/// Skips windows whose `last_frame_active` is older than the current
/// frame minus `STALE_FRAMES` (default 60) - keeps the payload free
/// of windows the user has stopped submitting. Tooltip / popup
/// windows (transient) are skipped via `flags.is_child` and other
/// internal flags.
/// Returns an empty `.{}` payload if no eligible windows exist
/// callers shouldn't have to special-case "no state yet".
pub fn serialize(gpa: Allocator, ctx: *const UiContext) ![]u8 {
    // Stale window threshold - frames since last submission past
    // which we drop the window from the persisted state. 60 frames
    // ≈ 1s at 60fps - comfortably more than any single-frame
    // hiccup but conservatively short enough that closed dialogs
    // don't linger forever in storage.
    const STALE_FRAMES: u64 = 60;

    // Build a list of `PersistedWindow` from live state. Allocate
    // into the same gpa - the final stringify reads from it and
    // we free the whole thing before returning.
    var out_windows: ArrayList(PersistedWindow) = .empty;
    defer out_windows.deinit(gpa);

    var it: std.AutoArrayHashMapUnmanaged(Id, *Window).Iterator = ctx.windows.iterator();
    while (it.next()) |entry| {
        const w: *Window = entry.value_ptr.*;
        // Filter: skip windows the user hasn't submitted recently.
        // `last_frame_active` is updated by `openWindow` every
        // frame the title is submitted; the gap is meaningful
        // because the GC pass otherwise leaves dead windows in
        // the map.
        if (ctx.frame_count > w.last_frame_active + STALE_FRAMES) {
            continue;
        }

        // Filter: skip children (those have their own IDs and
        // re-derive position/size from the parent each frame
        // persistence would be wrong). Child detection via the
        // `is_child` flag set by `findOrCreateChildWindow`.
        if (w.flags.is_child) {
            continue;
        }

        // P5.2: explicit caller opt-out. Windows with
        // `no_saved_settings` are excluded from the persistence
        // payload entirely — they were never written. Use for
        // transient inspector windows, debug overlays, or any UI
        // element whose pos/size shouldn't survive sessions.
        if (w.flags.no_saved_settings) {
            continue;
        }

        // (Future: skip popups + tooltips once those flag fields
        // land on `WindowFlags`. Today only `is_child` is wired;
        // popups submit through a separate path that doesn't
        // persist anything, so this filter is sufficient.)

        try out_windows.append(gpa, .{
            .name = w.name_buf[0..w.name_len],
            .pos = .{ w.pos[0], w.pos[1] },
            .size = .{ w.size[0], w.size[1] },
            .scroll_y = w.scroll_y,
            .scroll_x = w.scroll_x,
            .user_resized = w.user_resized,
            .tab_bars = try collectTabBarsFor(gpa, ctx, w.name_buf[0..w.name_len]),
        });
    }

    // collect dock tree nodes. Walk
    // `ctx.dock.nodes`, build a `PersistedDockNode` per entry.
    // For leaves, resolve `window_ids` → names (by reverse-
    // lookup in `ctx.windows`). For splits, copy ratio/axis/
    // size_ref verbatim. Empty array if no docking is in use.
    var out_nodes: ArrayList(PersistedDockNode) = .empty;
    defer {
        // Free the leaf_window_names allocations we built per
        // node. String contents are copied into the.zon
        // output by stringify; we own the slice array here.
        for (out_nodes.items) |pn| {
            if (pn.leaf_window_names.len > 0) {
                gpa.free(pn.leaf_window_names);
            }
        }
        out_nodes.deinit(gpa);
    }
    {
        var dn_it: std.AutoHashMapUnmanaged(Id, *DockNode).Iterator = ctx.dock.nodes.iterator();
        while (dn_it.next()) |entry| {
            const node: *const DockNode = entry.value_ptr.*;
            const flags_bits: u16 = @bitCast(node.flags);
            const parent_id: u32 = node.parent_id orelse 0;
            if (node.split) |s| {
                try out_nodes.append(gpa, .{
                    .id = node.id,
                    .parent_id = parent_id,
                    .flags = flags_bits,
                    .is_split = true,
                    .split_axis = @intFromEnum(s.axis),
                    .split_ratio = s.ratio,
                    .split_child_a = s.child_ids[0],
                    .split_child_b = s.child_ids[1],
                    .split_size_ref_a = s.size_ref[0] orelse -1,
                    .split_size_ref_b = s.size_ref[1] orelse -1,
                });
            } else if (node.leaf) |leaf| {
                // Resolve window_ids → names. Linear scan over
                // ctx.windows for each id - leaves typically
                // have ≤ ~10 docked windows so the cost is fine.
                var names: ArrayList([]const u8) = .empty;
                errdefer names.deinit(gpa);
                for (leaf.window_ids.items) |wid| {
                    if (ctx.windows.get(wid)) |w| {
                        try names.append(gpa, w.name_buf[0..w.name_len]);
                    }
                }
                const sel_name: []const u8 = blk: {
                    if (leaf.selected_window_id == 0) {
                        break :blk "";
                    }
                    if (ctx.windows.get(leaf.selected_window_id)) |w| {
                        break :blk w.name_buf[0..w.name_len];
                    }
                    break :blk "";
                };
                try out_nodes.append(gpa, .{
                    .id = node.id,
                    .parent_id = parent_id,
                    .flags = flags_bits,
                    .is_split = false,
                    .leaf_window_names = try names.toOwnedSlice(gpa),
                    .leaf_selected_window_name = sel_name,
                });
            }
        }
    }

    // Collect persistable extension state. Walk ext_storage slots
    // with `has_persist == true`, ask each slot's serialize_fn to
    // encode the typed inner map as a zon-text payload. Each
    // payload owns its allocation — they're freed after stringify
    // has copied them into the outer buffer.
    var out_ext: ArrayList(PersistedExtEntry) = .empty;
    defer out_ext.deinit(gpa);
    {
        var es_it: std.AutoHashMapUnmanaged(u64, ExtStorageSlot).Iterator = ctx.ext_storage.iterator();
        while (es_it.next()) |kv| {
            const slot: *ExtStorageSlot = kv.value_ptr;
            if (!slot.has_persist) {
                continue;
            }
            // Both fn ptrs were bound when has_persist was set.
            const ser_fn: SerializeExtFn = slot.serialize_fn orelse continue;
            const text: []u8 = ser_fn(slot.map, gpa) catch continue;
            try out_ext.append(gpa, .{
                .type_name = slot.type_name orelse "",
                .payload = text,
            });
        }
    }
    defer {
        // Free each per-type payload now that out has copied
        // them into the outer buffer.
        for (out_ext.items) |e| gpa.free(e.payload);
    }

    const payload: PersistedState = .{
        .version = current_version,
        .windows = out_windows.items,
        .dock_nodes = out_nodes.items,
        // Style capture: emit only when the in-memory style has
        // diverged from `dark_default`. Comparison is field-level
        // via stdlib's structural equality. When equal, leave
        // `null` to save ~600 bytes per save.
        .style = if (styleIsDefault(&ctx.style))
            null
        else
            PersistedStyle.fromStyle(&ctx.style),
        .ext_state = out_ext.items,
    };

    var out: std.Io.Writer.Allocating = .init(gpa);
    errdefer out.deinit();
    try std.zon.stringify.serialize(payload, .{}, &out.writer);
    // Free the inner tab_bars allocations we built up for each
    // window (the slices are owned by us, the writer just copied
    // their string contents into the output buffer).
    for (out_windows.items) |pw| {
        gpa.free(pw.tab_bars);
    }
    return try out.toOwnedSlice();
}

// ============================================================================
// SECTION 3 - Deserialization + pending-map application
// ============================================================================

/// Reverse-lookup a window by its user-given name. Linear scan
/// over `ctx.windows`; typical window counts are ≤ 50, so cheap.
/// Returns null when no match exists - caller treats as
/// "window not submitted yet; degrade gracefully".
fn findWindowIdByName(ctx: *const UiContext, name: []const u8) ?u32 {
    var it: std.AutoArrayHashMapUnmanaged(Id, *Window).Iterator = ctx.windows.iterator();
    while (it.next()) |entry| {
        const w: *Window = entry.value_ptr.*;
        if (eql(u8, w.name_buf[0..w.name_len], name)) {
            return entry.key_ptr.*;
        }
    }
    return null;
}

/// consume the pending dock tree (if any),
/// rebuilding `ctx.dock` to match. Called by `dockSpace()` the
/// first time a dockspace is submitted after load. Walks the
/// flat `PersistedDockNode[]` and:
///   1. Creates every node in `ctx.dock.nodes` with the original
///      `id` (preserves stable ids across reloads).
///   2. Wires split-vs-leaf role, ratio, axis, child_ids,
///      size_ref, flags.
///   3. For leaves: resolves window NAMES to live `Window` ids
///      via `findOrCreateWindow`. Sets `dock_node_id` on each
///      window so they render in the docked path.
///   4. Bumps `ctx.dock.next_node_id` past the max observed id
///      so future fresh allocations don't collide.
/// On any structural inconsistency (a child_id that doesn't
/// resolve, a leaf with no recoverable windows), the offending
/// node is skipped but the rest of the tree is preserved.
/// Persistence is a hint, never a hard requirement - a broken
/// payload degrades to "fresh tree", not a crash.
/// Returns true if the tree was restored (even partially), or
/// false if pending_dock_tree was empty.
pub fn tryRestoreDockTree(gpa: Allocator, ctx: *UiContext) bool {
    if (ctx.pending_dock_tree.len == 0) {
        return false;
    }
    defer freePendingDockTree(gpa, ctx);

    // Pass 1: create nodes by id. Split vs leaf determined by
    // the flat record. Skip nodes whose id is already taken
    // (shouldn't happen on a fresh ctx, but stay defensive).
    var max_id: u32 = 0;
    for (ctx.pending_dock_tree) |pn| {
        if (pn.id > max_id) {
            max_id = pn.id;
        }
        if (ctx.dock.nodes.contains(pn.id)) {
            continue;
        }
        const node: *DockNode = gpa.create(DockNode) catch continue;
        const parent_opt: ?u32 = if (pn.parent_id == 0) null else pn.parent_id;
        node.* = .{
            .id = pn.id,
            .parent_id = parent_opt,
            .flags = @bitCast(pn.flags),
        };
        ctx.dock.nodes.put(gpa, pn.id, node) catch {
            gpa.destroy(node);
            continue;
        };
    }

    // Pass 2: populate role data. Done second so child_id
    // lookups in pass 1 can resolve to existing pointers (both
    // children must exist before we wire the split that
    // references them).
    for (ctx.pending_dock_tree) |pn| {
        const node: *DockNode = ctx.dock.nodes.get(pn.id) orelse continue;
        if (pn.is_split) {
            const a_ok: bool = ctx.dock.nodes.contains(pn.split_child_a);
            const b_ok: bool = ctx.dock.nodes.contains(pn.split_child_b);
            if (!a_ok or !b_ok) {
                continue;
            } // dangling - skip
            const axis: SplitAxis = @enumFromInt(pn.split_axis);
            node.split = .{
                .axis = axis,
                .ratio = pn.split_ratio,
                .child_ids = .{ pn.split_child_a, pn.split_child_b },
                .size_ref = .{
                    if (pn.split_size_ref_a >= 0) pn.split_size_ref_a else null,
                    if (pn.split_size_ref_b >= 0) pn.split_size_ref_b else null,
                },
            };
        } else {
            // Leaf - resolve window names to live window ids.
            // Names not currently registered in ctx.windows are
            // skipped (window may not have been re-submitted
            // yet; in that case the leaf reappears empty, which
            // is correct degradation).
            var leaf: DockNode.LeafData = .{};
            for (pn.leaf_window_names) |name| {
                const w_id: Id = findWindowIdByName(ctx, name) orelse continue;
                leaf.window_ids.append(gpa, w_id) catch continue;
                // Mark the window as docked. pre_dock_pos/size
                // are left null - the original floating rect is
                // not part of persistence, so undock-restore
                // (5.5h) yields the window's current pos/size
                // (which the layout pass overwrites with the
                // leaf rect anyway).
                if (ctx.windows.get(w_id)) |w| {
                    w.dock_node_id = pn.id;
                }
            }
            // Selected tab: resolve by name; default to first
            // window if the selected name doesn't resolve or
            // the leaf is empty.
            if (pn.leaf_selected_window_name.len > 0) {
                if (findWindowIdByName(ctx, pn.leaf_selected_window_name)) |sel| {
                    leaf.selected_window_id = sel;
                }
            }
            if (leaf.selected_window_id == 0 and leaf.window_ids.items.len > 0) {
                leaf.selected_window_id = leaf.window_ids.items[0];
            }
            node.leaf = leaf;
        }
    }

    // Bump next_node_id past the max observed so fresh splits
    // post-restore don't reuse ids that were just claimed.
    if (max_id >= ctx.dock.next_node_id) {
        ctx.dock.next_node_id = max_id + 1;
    }
    return true;
}

pub const Error = error{
    /// Payload was syntactically invalid zon, or didn't match the
    /// `PersistedState` shape. Caller should fall back to empty
    /// persistence.
    InvalidPayload,
} || Allocator.Error;

// ============================================================================
// SECTION 3.5 - Auto-load / auto-save wiring (wasm-only)
// ============================================================================
// The storage transport (localStorage round-trip) lives in
// `src/web.zig` behind `extern "dom"` declarations that don't
// link on host builds. We comptime-gate the auto-* helpers on
// `is_wasm` so host tests / standalone runners can use the pure
// `serialize` / `apply` pair without dragging in the extern
// imports. On host, `tryAutoLoad` / `tryAutoSave` are no-ops
// that return immediately.
// The wiring goes through `UiContext.persistence_key`:
//   - User sets `ctx.persistence_key = "my_demo"` once at startup.
//   - On the FIRST `beginFrameRaw` call (`frame_count == 0`),
//     `tryAutoLoad(ctx)` reads localStorage key "zimr_my_demo",
//     parses it via `apply`, and the pending map is ready to graft
//     into windows as they're created during the frame's
//     `findOrCreateWindow` calls.
//   - Every `save_cadence_frames` frames in `endFrame`,
//     `tryAutoSave(ctx)` re-serializes the live windows and writes
//     back to localStorage. Save is unconditional on the cadence
//     no dirty-tracking yet (filed as 5.1c optimization).
// Errors are swallowed (log only): persistence is a "best effort"
// feature; a quota-exceeded or parse-failure shouldn't prevent the
// frame from rendering.

// (Turn 414: `builtin` is already imported at the top of ui.zig.
// `is_wasm` is the local const we keep here since it's only used
// by the persistence layer's auto-save / auto-load paths.)
const is_wasm = builtin.target.cpu.arch.isWasm();

/// Save cadence - every N frames, write the current state to
/// localStorage. At 60fps this is ~1s. Bumping this higher
/// reduces storage churn at the cost of "freshness" if the tab
/// is suddenly closed; lower trades the other way. Tunable per-
/// context via `ctx.persistence_save_cadence` (override field
/// added when needed; today the constant is fine).
pub const save_cadence_frames: u64 = 60;

/// Wasm-only: read localStorage for `ctx.persistence_key`, parse
/// the payload, populate `ctx.pending_persistence`. No-op on
/// host or when no key is set. Called from `beginFrameRaw` on
/// the first frame.
pub fn tryAutoLoad(ctx: *UiContext) void {
    if (!is_wasm) {
        return;
    }
    const key: []const u8 = ctx.persistence_key orelse return;

    // Don't reload over an already-populated pending map - that's
    // either a test that manually loaded or a previous frame's
    // load that hasn't finished consuming. Idempotency: subsequent
    // beginFrames won't clobber the in-progress load.
    if (ctx.pending_persistence.count() > 0) {
        return;
    }

    // Web module is wasm-only by file - import it inside the
    // is_wasm gate so host builds don't try to resolve it.
    const web = @import("web.zig").dom;
    const loaded: ?[]u8 = web.persistence_load(ctx.gpa, key) catch null;
    const bytes: []u8 = loaded orelse return;
    defer ctx.gpa.free(bytes);

    // `apply` wants a `[:0]const u8`. Dup + sentinel-terminate.
    const sentinel: [:0]u8 = ctx.gpa.allocSentinel(u8, bytes.len, 0) catch return;
    defer ctx.gpa.free(sentinel);
    @memcpy(sentinel, bytes);

    apply(ctx.gpa, ctx, sentinel) catch {
        // Bad payload - fresh start. Same recovery as a missing
        // key. Log eventually; for now silent.
    };
}

/// Wasm-only: serialize live state and write to localStorage.
/// Called from `endFrame` every `save_cadence_frames` frames.
/// No-op on host or without a key.
pub fn tryAutoSave(ctx: *const UiContext) void {
    if (!is_wasm) {
        return;
    }
    const key: []const u8 = ctx.persistence_key orelse return;
    if (ctx.frame_count == 0 or ctx.frame_count % save_cadence_frames != 0) {
        return;
    }

    const bytes: []u8 = serialize(ctx.gpa, ctx) catch return;
    defer ctx.gpa.free(bytes);

    const web = @import("web.zig").dom;
    // Status code 1 = quota exceeded, 2 = unavailable. We don't
    // surface these today - silently best-effort. Future: route
    // through a UiContext callback so demos can log/notify.
    _ = web.persistence_save(key, bytes);
}

// ============================================================================
// SECTION 4 - Tests
// ============================================================================

// All tests construct UiContext directly (no app runner involved).
// `frame_arena` is required (no default) - this helper centralizes
// the boilerplate so future test cases stay short.
fn testCtx(gpa: Allocator) UiContext {
    return .{
        .gpa = gpa,
        .frame_arena = FrameArena.init(gpa, ui_frame_arena_ceiling, "ui"),
    };
}

test "serialize: empty context produces empty windows array" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // Should at minimum contain the version field and an empty
    // windows array.
    try expect(std.mem.indexOf(u8, bytes, ".version") != null);
    try expect(std.mem.indexOf(u8, bytes, ".windows") != null);
}

test "serialize: walks live windows, captures pos/size/scroll" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    // Build a window directly (bypass beginFrame plumbing).
    const w: *Window = try gpa.create(Window);
    w.* = .{
        .id = 0xCAFEBABE,
        .pos = .{ 50, 100 },
        .size = .{ 300, 200 },
        .scroll_y = 12.5,
        .user_resized = true,
        .last_frame_active = ctx.frame_count, // not stale
    };
    const name: []const u8 = "my-window";
    @memcpy(w.name_buf[0..name.len], name);
    w.name_len = name.len;
    try ctx.windows.put(gpa, w.id, w);

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // Spot-check serialized content
    try expect(std.mem.indexOf(u8, bytes, "my-window") != null);
    try expect(std.mem.indexOf(u8, bytes, "12.5") != null);
}

test "serialize: skips stale windows (older than STALE_FRAMES)" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    ctx.frame_count = 1000;
    defer ctx.deinit();

    const w: *Window = try gpa.create(Window);
    w.* = .{
        .id = 0xCAFEBABE,
        .pos = .{ 50, 100 },
        .size = .{ 300, 200 },
        .last_frame_active = 100, // 900 frames stale
    };
    const name: []const u8 = "stale-window";
    @memcpy(w.name_buf[0..name.len], name);
    w.name_len = name.len;
    try ctx.windows.put(gpa, w.id, w);

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    try expect(std.mem.indexOf(u8, bytes, "stale-window") == null);
}

test "apply: parses zon and populates pending_persistence" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{
        \\            .name = "alpha",
        \\            .pos = .{ 10, 20 },
        \\            .size = .{ 300, 200 },
        \\            .scroll_y = 5,
        \\            .user_resized = true,
        \\        },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);

    try expectEqual(@as(usize, 1), ctx.pending_persistence.count());
    const pw: PersistedWindow = ctx.pending_persistence.get("alpha") orelse return error.MissingEntry;
    try expectEqual(@as(f32, 10), pw.pos[0]);
    try expectEqual(@as(f32, 20), pw.pos[1]);
    try expectEqual(@as(f32, 300), pw.size[0]);
    try expectEqual(@as(f32, 5), pw.scroll_y);
    try expectEqual(true, pw.user_resized);
}

test "apply: version mismatch silently discards payload" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 99,
        \\    .windows = .{
        \\        .{ .name = "future", .pos = .{0, 0}, .size = .{100, 100} },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text); // doesn't error
    try expectEqual(@as(usize, 0), ctx.pending_persistence.count());
}

test "apply: malformed payload returns InvalidPayload" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 = "this is not zon";
    try expectError(error.InvalidPayload, apply(gpa, &ctx, text));
}

test "apply: defaults fill missing optional fields" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{ .name = "minimal", .pos = .{ 0, 0 }, .size = .{ 100, 100 } },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);
    const pw: PersistedWindow = ctx.pending_persistence.get("minimal") orelse return error.MissingEntry;
    try expectEqual(@as(f32, 0), pw.scroll_y);
    try expectEqual(false, pw.user_resized);
}

test "takeFor: returns pending entry once, then null on second call" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{ .name = "once", .pos = .{ 42, 99 }, .size = .{ 200, 150 } },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);

    const first: PersistedWindow = takeFor(&ctx, "once") orelse return error.MissingEntry;
    try expectEqual(@as(f32, 42), first.pos[0]);
    try expectEqual(@as(f32, 99), first.pos[1]);

    // Second call returns null - entry consumed.
    try expectEqual(@as(?PersistedWindow, null), takeFor(&ctx, "once"));
}

test "takeFor: unknown title returns null without removing other entries" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{ .name = "kept", .pos = .{ 0, 0 }, .size = .{ 1, 1 } },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);

    try expectEqual(@as(?PersistedWindow, null), takeFor(&ctx, "missing"));
    try expectEqual(@as(usize, 1), ctx.pending_persistence.count());
}

test "roundtrip: serialize → apply produces matching pending state" {
    const gpa: Allocator = std.testing.allocator;

    // Source ctx with one window
    var ctx_src: UiContext = testCtx(gpa);
    defer ctx_src.deinit();
    const w: *Window = try gpa.create(Window);
    w.* = .{
        .id = 1,
        .pos = .{ 11, 22 },
        .size = .{ 333, 444 },
        .scroll_y = 7,
        .user_resized = true,
        .last_frame_active = ctx_src.frame_count,
    };
    const name: []const u8 = "roundtrip";
    @memcpy(w.name_buf[0..name.len], name);
    w.name_len = name.len;
    try ctx_src.windows.put(gpa, w.id, w);

    const bytes: []u8 = try serialize(gpa, &ctx_src);
    defer gpa.free(bytes);

    // Sentinel-terminate for the parser (apply takes [:0]const u8).
    const sentinel: [:0]u8 = try gpa.allocSentinel(u8, bytes.len, 0);
    defer gpa.free(sentinel);
    @memcpy(sentinel, bytes);

    // Apply into a fresh ctx.
    var ctx_dst: UiContext = testCtx(gpa);
    defer ctx_dst.deinit();
    try apply(gpa, &ctx_dst, sentinel);

    const pw: PersistedWindow = ctx_dst.pending_persistence.get("roundtrip") orelse return error.RoundtripFailed;
    try expectEqual(@as(f32, 11), pw.pos[0]);
    try expectEqual(@as(f32, 22), pw.pos[1]);
    try expectEqual(@as(f32, 333), pw.size[0]);
    try expectEqual(@as(f32, 444), pw.size[1]);
    try expectEqual(@as(f32, 7), pw.scroll_y);
    try expectEqual(true, pw.user_resized);
}

test "apply: zero-length name entries are skipped" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{ .name = "", .pos = .{0,0}, .size = .{1,1} },
        \\        .{ .name = "real", .pos = .{0,0}, .size = .{1,1} },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);
    try expectEqual(@as(usize, 1), ctx.pending_persistence.count());
    try expect(ctx.pending_persistence.get("real") != null);
}

// ============================================================================
// SECTION 4.5 - Tab-bar persistence tests
// ============================================================================

test "tab bars: apply stages selections, takeTabBarSelection consumes" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{
        \\    .version = 1,
        \\    .windows = .{
        \\        .{
        \\            .name = "win",
        \\            .pos = .{0,0}, .size = .{100,100},
        \\            .tab_bars = .{
        \\                .{ .str_id = "bar1", .selected_tab_label = "Beta" },
        \\                .{ .str_id = "bar2", .selected_tab_label = "Tools" },
        \\            },
        \\        },
        \\    },
        \\}
    ;
    try apply(gpa, &ctx, text);
    try expectEqual(@as(usize, 2), ctx.pending_tab_bar_selections.count());

    const sel1: []const u8 = takeTabBarSelection(&ctx, "win", "bar1") orelse return error.MissingBar;
    defer gpa.free(@constCast(sel1));
    try expectEqualStrings("Beta", sel1);

    // Consumed - second call returns null.
    try expectEqual(@as(?[]const u8, null), takeTabBarSelection(&ctx, "win", "bar1"));

    // Other bar still present.
    const sel2: []const u8 = takeTabBarSelection(&ctx, "win", "bar2") orelse return error.MissingBar;
    defer gpa.free(@constCast(sel2));
    try expectEqualStrings("Tools", sel2);
}

test "tab bars: takeTabBarSelection returns null for unknown bar" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{ .version = 1, .windows = .{
        \\    .{ .name = "w", .pos=.{0,0}, .size=.{1,1},
        \\       .tab_bars = .{ .{ .str_id = "real", .selected_tab_label = "T" } } },
        \\}}
    ;
    try apply(gpa, &ctx, text);
    try expectEqual(@as(?[]const u8, null), takeTabBarSelection(&ctx, "w", "missing"));
    // The "real" entry is still present after the null lookup.
    try expectEqual(@as(usize, 1), ctx.pending_tab_bar_selections.count());
}

test "tab bars: empty selection label round-trips as no-preference" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    const text: [:0]const u8 =
        \\.{ .version = 1, .windows = .{
        \\    .{ .name = "w", .pos=.{0,0}, .size=.{1,1},
        \\       .tab_bars = .{ .{ .str_id = "bar", .selected_tab_label = "" } } },
        \\}}
    ;
    try apply(gpa, &ctx, text);

    // Entry IS staged even with empty label - distinct from "no
    // entry at all". Caller's openTabBar uses empty as "no
    // preference recorded; let first-tab auto-select".
    const sel: []const u8 = takeTabBarSelection(&ctx, "w", "bar") orelse return error.MissingBar;
    defer gpa.free(@constCast(sel));
    try expectEqualStrings("", sel);
}

test "serialize: tab bars with matching parent window emit selections" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    // Build a window and a tab-bar state with a selected tab.
    const w: *Window = try gpa.create(Window);
    w.* = .{
        .id = 0xAAAA,
        .pos = .{ 0, 0 },
        .size = .{ 100, 100 },
        .last_frame_active = ctx.frame_count,
    };
    const name: []const u8 = "parent";
    @memcpy(w.name_buf[0..name.len], name);
    w.name_len = name.len;
    try ctx.windows.put(gpa, w.id, w);

    // Tab bar with one tab; the tab is "selected_id".
    var bar_state: TabBarState = .{};
    const sid: []const u8 = "tabs";
    @memcpy(bar_state.parent_window_name_buf[0..name.len], name);
    bar_state.parent_window_name_len = name.len;
    @memcpy(bar_state.str_id_buf[0..sid.len], sid);
    bar_state.str_id_len = sid.len;
    var tab: TabState = .{ .id = 42 };
    const tlabel: []const u8 = "Alpha";
    @memcpy(tab.label_buf[0..tlabel.len], tlabel);
    tab.label_len = tlabel.len;
    try bar_state.tabs.append(gpa, tab);
    bar_state.selected_id = 42;
    try ctx.tab_bar_state.put(gpa, 0xBBBB, bar_state);

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // Spot-check: both str_id and the selected label appear.
    try expect(std.mem.indexOf(u8, bytes, "tabs") != null);
    try expect(std.mem.indexOf(u8, bytes, "Alpha") != null);
}

test "roundtrip: tab bar selection serializes and re-applies" {
    const gpa: Allocator = std.testing.allocator;

    var ctx_src: UiContext = testCtx(gpa);
    defer ctx_src.deinit();

    const w: *Window = try gpa.create(Window);
    w.* = .{ .id = 1, .pos = .{ 0, 0 }, .size = .{ 100, 100 }, .last_frame_active = ctx_src.frame_count };
    const name: []const u8 = "win";
    @memcpy(w.name_buf[0..name.len], name);
    w.name_len = name.len;
    try ctx_src.windows.put(gpa, w.id, w);

    var bar_state: TabBarState = .{};
    const sid: []const u8 = "bar";
    @memcpy(bar_state.parent_window_name_buf[0..name.len], name);
    bar_state.parent_window_name_len = name.len;
    @memcpy(bar_state.str_id_buf[0..sid.len], sid);
    bar_state.str_id_len = sid.len;
    var tab: TabState = .{ .id = 7 };
    const tlabel: []const u8 = "Selected";
    @memcpy(tab.label_buf[0..tlabel.len], tlabel);
    tab.label_len = tlabel.len;
    try bar_state.tabs.append(gpa, tab);
    bar_state.selected_id = 7;
    try ctx_src.tab_bar_state.put(gpa, 2, bar_state);

    const bytes: []u8 = try serialize(gpa, &ctx_src);
    defer gpa.free(bytes);
    const sentinel: [:0]u8 = try gpa.allocSentinel(u8, bytes.len, 0);
    defer gpa.free(sentinel);
    @memcpy(sentinel, bytes);

    var ctx_dst: UiContext = testCtx(gpa);
    defer ctx_dst.deinit();
    try apply(gpa, &ctx_dst, sentinel);

    const sel: []const u8 = takeTabBarSelection(&ctx_dst, "win", "bar") orelse return error.MissingSelection;
    defer gpa.free(@constCast(sel));
    try expectEqualStrings("Selected", sel);
}

test "5.5k: serialize captures dock tree shape" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();

    // Build a small tree: root SPLIT (horizontal, ratio 0.3,
    // size_ref[0] = 200) with two leaf children, each holding
    // one named window.
    const u = Ui{ .ctx = &ctx };
    const root: Id = try ctx.dock.createLeaf(gpa, null);
    ctx.dock.lookup(root).?.flags = .{ .is_dockspace = true };
    const split_result: SplitResult = try splitNode(&ctx.dock, gpa, root, .right, 0.30);
    ctx.dock.lookup(root).?.split.?.size_ref[0] = 200;

    // Create two windows and dock them.
    _ = findOrCreateWindow(&ctx, "Tools", .{});
    _ = findOrCreateWindow(&ctx, "View", .{});
    u.dockBuilderDockWindow("Tools", split_result.a);
    u.dockBuilderDockWindow("View", split_result.b);

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // .zon output should contain the dock_nodes field and at
    // least one of each interesting bit of state.
    try expect(std.mem.indexOf(u8, bytes, ".dock_nodes") != null);
    try expect(std.mem.indexOf(u8, bytes, ".is_split = true") != null);
    try expect(std.mem.indexOf(u8, bytes, ".split_ratio") != null);
    try expect(std.mem.indexOf(u8, bytes, "Tools") != null);
    try expect(std.mem.indexOf(u8, bytes, "View") != null);
}

test "5.5k: round-trip serialize → apply → tryRestoreDockTree rebuilds tree" {
    const gpa: Allocator = std.testing.allocator;

    // Source ctx - build a tree and serialize.
    var src_ctx: UiContext = testCtx(gpa);
    defer src_ctx.deinit();
    const u_src = Ui{ .ctx = &src_ctx };
    const src_root: Id = try src_ctx.dock.createLeaf(gpa, null);
    src_ctx.dock.lookup(src_root).?.flags = .{ .is_dockspace = true };
    const split: SplitResult = try splitNode(&src_ctx.dock, gpa, src_root, .right, 0.40);
    src_ctx.dock.lookup(src_root).?.split.?.size_ref[0] = 250;
    src_ctx.dock.lookup(split.a).?.flags = .{ .no_split = true };
    _ = findOrCreateWindow(&src_ctx, "Tools", .{});
    _ = findOrCreateWindow(&src_ctx, "Viewport", .{});
    _ = findOrCreateWindow(&src_ctx, "Console", .{});
    u_src.dockBuilderDockWindow("Tools", split.a);
    u_src.dockBuilderDockWindow("Viewport", split.b);
    u_src.dockBuilderDockWindow("Console", split.b);

    const bytes: []u8 = try serialize(gpa, &src_ctx);
    defer gpa.free(bytes);
    const sentinel: [:0]const u8 = try dupeZ(gpa, bytes);
    defer gpa.free(@constCast(sentinel));

    // Destination ctx - apply, then create the windows by name
    // (the user's submission loop does this on first frame),
    // then drive tryRestoreDockTree.
    var dst_ctx: UiContext = testCtx(gpa);
    defer dst_ctx.deinit();
    try apply(gpa, &dst_ctx, sentinel);
    _ = findOrCreateWindow(&dst_ctx, "Tools", .{});
    _ = findOrCreateWindow(&dst_ctx, "Viewport", .{});
    _ = findOrCreateWindow(&dst_ctx, "Console", .{});

    const restored: bool = tryRestoreDockTree(gpa, &dst_ctx);
    try expect(restored);

    // Root preserved with is_dockspace flag.
    const dst_root: *DockNode = dst_ctx.dock.lookup(src_root) orelse return error.RootMissing;
    try expect(dst_root.flags.is_dockspace);
    try expect(dst_root.split != null);
    try expectApproxEqAbs(@as(f32, 0.40), dst_root.split.?.ratio, 0.001);
    try expectEqual(@as(?f32, 250), dst_root.split.?.size_ref[0]);

    // Tools leaf flags survived.
    const tools_leaf: *DockNode = dst_ctx.dock.lookup(split.a) orelse return error.LeafMissing;
    try expect(tools_leaf.flags.no_split);
    try expectEqual(@as(usize, 1), tools_leaf.leaf.?.window_ids.items.len);

    // Viewport leaf has 2 windows; selected should be the first
    // (Viewport was the first dockBuilderDockWindow call).
    const right_leaf: *DockNode = dst_ctx.dock.lookup(split.b) orelse return error.RightLeafMissing;
    try expectEqual(@as(usize, 2), right_leaf.leaf.?.window_ids.items.len);

    // Each docked window has its dock_node_id set.
    const tools: *Window = findOrCreateWindow(&dst_ctx, "Tools", .{});
    const viewport_win: *Window = findOrCreateWindow(&dst_ctx, "Viewport", .{});
    try expectEqual(@as(?u32, split.a), tools.dock_node_id);
    try expectEqual(@as(?u32, split.b), viewport_win.dock_node_id);

    // After restore, pending tree is freed.
    try expectEqual(@as(usize, 0), dst_ctx.pending_dock_tree.len);
}

test "5.5k: tryRestoreDockTree no-op when nothing pending" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();
    try expect(!tryRestoreDockTree(gpa, &ctx));
}

test "5.5k: leaf with no recoverable windows still creates an empty leaf" {
    const gpa: Allocator = std.testing.allocator;
    var src_ctx: UiContext = testCtx(gpa);
    defer src_ctx.deinit();
    const u_src = Ui{ .ctx = &src_ctx };
    const root: Id = try src_ctx.dock.createLeaf(gpa, null);
    src_ctx.dock.lookup(root).?.flags = .{ .is_dockspace = true };
    _ = findOrCreateWindow(&src_ctx, "GhostWindow", .{});
    u_src.dockBuilderDockWindow("GhostWindow", root);

    const bytes: []u8 = try serialize(gpa, &src_ctx);
    defer gpa.free(bytes);
    const sentinel: [:0]const u8 = try dupeZ(gpa, bytes);
    defer gpa.free(@constCast(sentinel));

    // Destination ctx WITHOUT GhostWindow created. Restore
    // should yield an empty leaf, not crash.
    var dst_ctx: UiContext = testCtx(gpa);
    defer dst_ctx.deinit();
    try apply(gpa, &dst_ctx, sentinel);
    const restored: bool = tryRestoreDockTree(gpa, &dst_ctx);
    try expect(restored);

    const leaf: *DockNode = dst_ctx.dock.lookup(root) orelse return error.LeafMissing;
    try expectEqual(@as(usize, 0), leaf.leaf.?.window_ids.items.len);
}

// ============================================================================
// - Style persistence
// ============================================================================

test "P4.1: serialize skips style block when style == dark_default" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();
    // ctx.style starts as dark_default — fresh ctx.

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // Should emit `.style = null,` rather than a full PersistedStyle block.
    try expect(std.mem.indexOf(u8, bytes, ".style = null") != null);
    // None of the per-color slot names should appear in the payload.
    try expect(std.mem.indexOf(u8, bytes, ".window_bg") == null);
    try expect(std.mem.indexOf(u8, bytes, ".text_link_underline") == null);
}

test "P4.1: serialize emits PersistedStyle block when style differs" {
    const gpa: Allocator = std.testing.allocator;
    var ctx: UiContext = testCtx(gpa);
    defer ctx.deinit();
    // Diverge from default: bump button color. One field is enough
    // to fail the default-equality check and trigger emission.
    ctx.style.button = .{ .r = 255, .g = 100, .b = 50, .a = 200 };

    const bytes: []u8 = try serialize(gpa, &ctx);
    defer gpa.free(bytes);

    // The block should be present this time.
    try expect(std.mem.indexOf(u8, bytes, ".style = null") == null);
    try expect(std.mem.indexOf(u8, bytes, ".button = ") != null);
}

test "P4.1: serialize → apply round-trips a customized style" {
    const gpa: Allocator = std.testing.allocator;
    var src_ctx: UiContext = testCtx(gpa);
    defer src_ctx.deinit();
    // Customize several fields covering colors, Vec2, scalar, and i32.
    src_ctx.style.button = .{ .r = 100, .g = 200, .b = 50, .a = 220 };
    src_ctx.style.text_link_underline = .{ .r = 1, .g = 2, .b = 3, .a = 4 };
    src_ctx.style.window_padding = .{ 12, 16 };
    src_ctx.style.disabled_alpha = 0.42;
    src_ctx.style.line_spacing = 7;

    const bytes: []u8 = try serialize(gpa, &src_ctx);
    defer gpa.free(bytes);

    const sentinel: [:0]u8 = try gpa.allocSentinel(u8, bytes.len, 0);
    defer gpa.free(sentinel);
    @memcpy(sentinel, bytes);

    var dst_ctx: UiContext = testCtx(gpa);
    defer dst_ctx.deinit();
    try apply(gpa, &dst_ctx, sentinel);

    try expectEqual(src_ctx.style.button, dst_ctx.style.button);
    try expectEqual(src_ctx.style.text_link_underline, dst_ctx.style.text_link_underline);
    try expectEqual(src_ctx.style.window_padding, dst_ctx.style.window_padding);
    try expectEqualDeep(src_ctx.style.disabled_alpha, dst_ctx.style.disabled_alpha);
    try expectEqual(src_ctx.style.line_spacing, dst_ctx.style.line_spacing);
}

test "P4.1: apply preserves caller-bound font pointer" {
    const gpa: Allocator = std.testing.allocator;

    // Source: themed but with NO font bound (the saved payload
    // can't carry the font; this is the realistic save state).
    var src_ctx: UiContext = testCtx(gpa);
    defer src_ctx.deinit();
    src_ctx.style.button = .{ .r = 50, .g = 60, .b = 70, .a = 80 };

    const bytes: []u8 = try serialize(gpa, &src_ctx);
    defer gpa.free(bytes);
    const sentinel: [:0]u8 = try gpa.allocSentinel(u8, bytes.len, 0);
    defer gpa.free(sentinel);
    @memcpy(sentinel, bytes);

    // Destination: caller has bound a font (e.g. via
    // loadFontFromTtfBytes BEFORE calling apply). apply must NOT
    // clobber that binding.
    var dst_ctx: UiContext = testCtx(gpa);
    defer dst_ctx.deinit();
    const sentinel_font: types.Font = undefined;
    dst_ctx.style.font = &sentinel_font;

    try apply(gpa, &dst_ctx, sentinel);

    try expectEqual(@as(?*const types.Font, &sentinel_font), dst_ctx.style.font);
    // Color did get applied.
    try expectEqual(src_ctx.style.button, dst_ctx.style.button);
}

// ============================================================================
// - Theme presets
// ============================================================================

test "P4.2: applyPreset(.light) swaps every color, preserves numerics" {
    var s: Style = Style.dark_default;
    s.font_size = 20; // user choice — should survive
    s.window_padding = .{ 16, 16 };
    s.disabled_alpha = 0.42;

    s.applyPreset(.light);

    // A representative color shifted to the light theme.
    try expectEqual(Style.light_default.window_bg, s.window_bg);
    try expectEqual(Style.light_default.text, s.text);
    // Numeric chrome untouched.
    try expectEqual(@as(f32, 20), s.font_size);
    try expectEqual(@as(f32, 16), s.window_padding[0]);
    try expectEqual(@as(f32, 0.42), s.disabled_alpha);
}

test "P4.2: applyPreset(.classic) and back to .dark round-trips colors" {
    var s: Style = Style.dark_default;
    s.applyPreset(.classic);
    try expectEqual(Style.classic_default.button, s.button);

    s.applyPreset(.dark);
    try expectEqual(Style.dark_default.button, s.button);
    try expectEqual(Style.dark_default.window_bg, s.window_bg);
}

test "P4.2: applyPreset preserves font pointer" {
    var s: Style = Style.dark_default;
    const sentinel_font: types.Font = undefined;
    s.font = &sentinel_font;

    s.applyPreset(.light);
    try expectEqual(@as(?*const types.Font, &sentinel_font), s.font);

    s.applyPreset(.classic);
    try expectEqual(@as(?*const types.Font, &sentinel_font), s.font);
}

// ============================================================================
// Screenshot — DrawList → raster → RGBA8 → PNG
// ============================================================================
//
// Host-only debug + regression-test tool.  Walks every window's
// `DrawList` in a `UiContext` post-`beginFrame`+widget-submission
// state, replays the commands into a fresh `raster.Context` software
// framebuffer, then encodes the buffer as a PNG.  Lets us inspect
// UI layout WITHOUT running a real WebGL/wasm session — invaluable
// for debugging layout bugs (where the visual position is wrong
// but the state looks fine) and for snapshot-style regression
// tests built on `snapshot()` below.
//
// Why this exists:
// The canonical "looks fine in tests, broken on screen" failure
// surfaced from a phone screenshot showing tab-bar widgets
// visually overlapping while every TabBar unit test passed.  A
// self-driven screenshot path closes that gap — we can take a
// PNG mid-test, eyeball it (or assert over pixel rects), and
// catch layout bugs at test time.
//
// Scope and limitations:
//   - **Text**: rendered as a label-colored rectangle at the
//     text's bounding rect.  No glyph rasterization on the raster
//     side — that needs the font atlas uploaded as an raster
//     texture and a textured-quad emit path per glyph.  For
//     layout debugging, the box footprint is sufficient: you
//     SEE where text was placed even if you can't READ it.
//   - **Textured quads** (images, glyph atlases): rendered as a
//     solid-color placeholder using the cmd's tint color.
//   - **Clipping**: push_clip / pop_clip translate to raster's
//     scissor enable/disable pair.  Nested clips track a stack
//     and apply the deepest current clip rect.
//   - **Lines / polylines / triangles / circles / ngons /
//     bezier**: all rendered as immediate-mode triangles via the
//     raster rasterizer.  Outlines approximated by a series of
//     thin filled quads (no GL_LINE_WIDTH semantics in raster).
//
// Public API:
//   - `renderToBytes(gpa, ctx, w, h) ![]u8` — rasterize all
//     windows, return a freshly-allocated RGBA8 byte buffer.
//   - `renderToPng(gpa, ctx, w, h, path) !void` — convenience
//     that also encodes to PNG and writes to disk.
//   - `snapshot(gpa, ctx, w, h, ref_path) !PixelDiff` —
//     regression-test helper.  First run writes the baseline;
//     subsequent runs diff against it.  Refresh a baseline by
//     deleting the PNG.
//   - `comparePixels(a, b, tolerance) PixelDiff` — generic
//     RGBA8 byte-compare with a per-channel tolerance.  Not
//     UI-specific; useable on any same-size RGBA8 buffers.
//   - `loadPng(gpa, path) !codecs.png.Image` — generic PNG
//     read.  Same disclaimer.

/// Internal: draw a colored rectangle at the bbox where a text
/// command WOULD render its glyphs.  Color uses the text cmd's own
/// color so semantically-distinct text (regular, disabled, link,
/// error) still reads as distinct in the screenshot.  Alpha is
/// kept around 2/3 of the source so overlapping bboxes still show
/// layering without dominating the image.  Height = font_size;
/// width is a coarse estimate of `len * size * 0.5`.
fn drawTextBbox(
    sw: *raster.Context,
    s: []const u8,
    pos: Vec2,
    size: f32,
    packed_col: u32,
) void {
    if (s.len == 0) {
        return;
    }
    const r8: u8 = @intCast(packed_col & 0xFF);
    const g8: u8 = @intCast((packed_col >> 8) & 0xFF);
    const b8: u8 = @intCast((packed_col >> 16) & 0xFF);
    const a8_full: u8 = @intCast((packed_col >> 24) & 0xFF);
    // 2/3 of source alpha — readable but doesn't dominate.
    const a8: u8 = @intCast((@as(u32, a8_full) * 2) / 3);
    const est_w: f32 = float(s.len) * size * 0.5;
    sw.disable(.texture_2d);
    sw.color4ub(r8, g8, b8, a8);
    sw.begin(.quads);
    sw.vertex2f(pos[0], pos[1]);
    sw.vertex2f(pos[0], pos[1] + size);
    sw.vertex2f(pos[0] + est_w, pos[1] + size);
    sw.vertex2f(pos[0] + est_w, pos[1]);
    sw.end();
}

/// Rasterize `ctx`'s current frame (every window's draw list) into
/// a fresh RGBA8 buffer of size `w * h * 4`.  Caller owns the slice
/// and must free with `gpa`.
/// The contract: this is called AFTER `ctx.beginFrame(...)` and
/// AFTER the caller has submitted every window's widgets, but
/// BEFORE `ctx.endFrame(...)` (which would normally replay against
/// real GL).  We replay against raster instead.
pub fn renderToBytes(
    gpa: Allocator,
    ctx: *UiContext,
    w: u32,
    h: u32,
) ![]u8 {
    const w_i: i32 = @intCast(w);
    const h_i: i32 = @intCast(h);

    var sw: raster.Context = try raster.Context.init(gpa, w_i, h_i);
    defer sw.deinit(gpa);

    // Set up an ortho projection mapping pixel coords directly to
    // NDC.  ui widgets work in screen pixels (top-left origin,
    // y-down).  raster's NDC is standard GL (y-up) so we put `top`
    // < `bottom` in the ortho call to flip — the y-down feel
    // matches what the rlgl path produces.
    sw.matrixMode(.projection);
    sw.loadIdentity();
    sw.ortho(0, @floatFromInt(w), @floatFromInt(h), 0, -1, 1);
    sw.matrixMode(.modelview);
    sw.loadIdentity();
    sw.viewport(0, 0, w_i, h_i);

    // Clear to a typical zimr ui demo background colour.
    sw.clearColor(.{ .r = 15, .g = 23, .b = 42, .a = 255 });
    sw.clear(.{ .color = true });

    // Alpha-blend so semi-transparent rects compose correctly.
    sw.enable(.blend);
    sw.blendFunc(.src_alpha, .one_minus_src_alpha);

    // Synthetic WindowState — `DrawList.render` reads this for
    // DPR-aware scissor scaling.  On raster there's no DPR, so
    // screen_w == render_w (factor of 1).
    const ws: runtime.core.WindowState = .{
        .screen_width = w_i,
        .screen_height = h_i,
        .render_width = w_i,
        .render_height = h_i,
    };
    // Synthetic FontCache + ShapesTextureState.  Both zero-init;
    // the screenshot path doesn't bind real font glyph textures, so
    // text quads sample white (no texture bound) and render as solid
    // coloured rectangles.  That's actually the right placeholder
    // behaviour for layout debugging.
    const shapes_state: shapes2d.ShapesTextureState = .{};
    const font_cache: text2d.FontCache = .{};

    // In normal run-mode `endFrame` drains deferred dockspace tab
    // emission into `ctx.foreground_dl`.  The screenshot path
    // submits-then-renders without calling endFrame, so we have
    // to call this directly here so single-frame PNG renders
    // include the dock tabs.
    flushDockTabsToForeground(ctx);

    // Walk every active window's draw list and replay through the
    // SAME `DrawList.render` the rlgl path uses (which is generic
    // over `gl: anytype`).  HashMap iteration order isn't
    // submission order, so overlapping windows may composite in
    // arbitrary z-order — fine for layout debug.
    // Two passes per window:
    //   1. Text-bbox placeholders.  `text2d.draw` short-
    //      circuits when `font_cache.font.texture.id == 0` (an
    //      unloaded font), so we'd see no text at all using the
    //      real render path.  Pre-emit faint rectangles at each
    //      text cmd's bbox so screenshots show WHERE text lives.
    //   2. The real DrawList.render — paints rects, lines, scissor
    //      stack, etc., and silently no-ops the text cmds.
    var it: std.AutoArrayHashMapUnmanaged(Id, *Window).Iterator = ctx.windows.iterator();
    while (it.next()) |entry| {
        const win: *Window = entry.value_ptr.*;
        if (win.last_frame_active != ctx.frame_count) {
            continue;
        }
        // Pass 1: text bboxes.
        for (win.draw_list.cmds.items) |cmd| switch (cmd) {
            .text => |c| drawTextBbox(&sw, c.s, c.pos, c.size, c.col),
            else => {},
        };
        // Pass 2: the real renderer.
        win.draw_list.render(&sw, &ws, &shapes_state, &font_cache);
    }

    // Foreground draw list — tooltips, popup overlays, AND the
    // deferred dockspace tab-bar emission.  Rendered AFTER every
    // window so overlays composite on top of regular content.
    // Text-bbox pass first, then the real renderer (same shape).
    for (ctx.foreground_dl.cmds.items) |cmd| switch (cmd) {
        .text => |c| drawTextBbox(&sw, c.s, c.pos, c.size, c.col),
        else => {},
    };
    ctx.foreground_dl.render(&sw, &ws, &shapes_state, &font_cache);

    // Read out the framebuffer and hand it to the caller.
    const src: []const u8 = sw.colorBufferBytes();
    const dst: []u8 = try gpa.alloc(u8, src.len);
    @memcpy(dst, src);
    return dst;
}

/// Convenience: rasterize, encode, write to disk. Takes the application's
/// `io` (the `std.Io` chosen in `main`), threaded like the allocator.
pub fn renderToPng(
    gpa: Allocator,
    io: std.Io,
    ctx: *UiContext,
    w: u32,
    h: u32,
    out_path: []const u8,
) !void {
    const bytes: []u8 = try renderToBytes(gpa, ctx, w, h);
    defer gpa.free(bytes);

    const png_bytes: []u8 = try codecs.png.encode(gpa, bytes, w, h);
    defer gpa.free(png_bytes);

    var f: std.Io.File = try std.Io.Dir.cwd().createFile(io, out_path, .{});
    defer f.close(io);
    try f.writeStreamingAll(io, png_bytes);
}

/// Result of comparing two RGBA8 buffers pixel-by-pixel.
pub const PixelDiff = struct {
    /// Total pixels examined.  Always `w * h`.
    total: u32,
    /// Pixels where at least one channel differs by > tolerance.
    differing: u32,
    /// Largest per-channel absolute difference observed.
    max_channel_delta: u8,

    pub fn fraction(self: PixelDiff) f32 {
        if (self.total == 0) {
            return 0;
        }
        return float(self.differing) / float(self.total);
    }
};

/// Per-pixel RGBA8 byte-compare with a configurable per-channel
/// tolerance.  `a` and `b` must be the same length, a multiple of 4.
/// Not UI-specific; works on any same-size RGBA8 buffers.
pub fn comparePixels(
    a: []const u8,
    b: []const u8,
    tolerance: u8,
) PixelDiff {
    assert(a.len == b.len, @src());
    assert(a.len % 4 == 0, @src());
    var diff: PixelDiff = .{ .total = @intCast(a.len / 4), .differing = 0, .max_channel_delta = 0 };
    var i: usize = 0;
    while (i < a.len) : (i += 4) {
        var px_different: bool = false;
        var k: usize = 0;
        while (k < 4) : (k += 1) {
            const av: i16 = @intCast(a[i + k]);
            const bv: i16 = @intCast(b[i + k]);
            const d: u8 = @intCast(@abs(av - bv));
            if (d > diff.max_channel_delta) {
                diff.max_channel_delta = d;
            }
            if (d > tolerance) {
                px_different = true;
            }
        }
        if (px_different) {
            diff.differing += 1;
        }
    }
    return diff;
}

/// Read a PNG file into an `Image`.  Caller owns the result;
/// release with `image.deinit(gpa)`. Takes the application's `io`.
pub fn loadPng(
    gpa: Allocator,
    io: std.Io,
    path: []const u8,
) !codecs.png.Image {
    var f: std.Io.File = try std.Io.Dir.cwd().openFile(io, path, .{});
    defer f.close(io);
    const st: std.Io.File.Stat = try f.stat(io);
    const bytes: []u8 = try gpa.alloc(u8, st.size);
    defer gpa.free(bytes);
    const read: usize = try f.readPositionalAll(io, bytes, 0);
    return try codecs.png.decode(gpa, bytes[0..read]);
}

/// Snapshot test: rasterize `ctx`, compare against the PNG at
/// `ref_path`.  On mismatch — OR when the reference doesn't yet
/// exist — write the current render to `ref_path` and return the
/// diff so the caller can decide what to do.
///
/// The "first run writes the baseline" behavior is intentional:
/// it makes adding a snapshot test as cheap as adding a regular
/// test.  Refresh a snapshot by deleting the reference PNG and
/// re-running.
pub fn snapshotPng(
    gpa: Allocator,
    io: std.Io,
    ctx: *UiContext,
    w: u32,
    h: u32,
    ref_path: []const u8,
) !PixelDiff {
    const fresh: []u8 = try renderToBytes(gpa, ctx, w, h);
    defer gpa.free(fresh);

    const ref_image: codecs.png.Image = loadPng(gpa, io, ref_path) catch |err| switch (err) {
        error.FileNotFound => {
            // First-run baseline write.  Encode the fresh render
            // and save it as the reference.  Caller sees a clean
            // pass (differing == 0) and can move on.
            try renderToPng(gpa, io, ctx, w, h, ref_path);
            return PixelDiff{ .total = w * h, .differing = 0, .max_channel_delta = 0 };
        },
        else => return err,
    };
    defer ref_image.deinit(gpa);

    if (ref_image.width != w or ref_image.height != h) {
        return error.SnapshotDimensionsMismatch;
    }
    return comparePixels(fresh, ref_image.pixels, 0);
}

test "wantCaptureMouse: answers correctly when queried BEFORE this frame's windows are submitted" {
    // The natural way to write an update is: gate the scene input on
    // `!u.wantCaptureMouse()`, THEN draw the UI. That means the query happens
    // while `frame_windows` is still EMPTY. Hit-testing only that list made the
    // query return false, so the pointer clicked straight through the panel —
    // tapping a button also dragged the scene underneath it (seen on-device in
    // scissor_test; decals + zimrphysics2d_demo had the same latent bug).
    // The fix: `endFrame` snapshots each window's RECT, and `wantCaptureMouse`
    // falls back to that snapshot. This test pins that behaviour.
    var ctx = UiContext.init(std.testing.allocator);
    defer ctx.deinit();

    // A real Window (so it is safe to dereference) at a known rect.
    var w: Window = .{
        .id = 1,
        .pos = .{ 100, 50 },
        .size = .{ 200, 120 }, // covers x 100..300, y 50..170
    };
    ctx.frame_windows.append(&w) catch unreachable;

    // End of frame N: the rect is snapshotted while the window is alive.
    ctx.endFrameNoRender();
    try expectEqual(@as(usize, 1), ctx.prev_window_rects.len);

    // Frame N+1 begins — `frame_windows` is cleared, nothing submitted yet.
    var gl_dummy: Gl = .{};
    const shapes_dummy: shapes2d.ShapesTextureState = .{};
    const font_dummy: text2d.FontCache = .{};
    _ = ctx.beginFrameRaw(.{}, null, 800, 600, &gl_dummy, &shapes_dummy, &font_dummy);
    try expectEqual(@as(usize, 0), ctx.frame_windows.len);

    const u: Ui = .{ .ctx = &ctx };

    // Pointer INSIDE the panel, queried before any window is submitted: the UI
    // must claim it. (Before the fix this returned false — the click-through.)
    ctx.input.mouse_pos = .{ 150, 90 };
    try expect(u.wantCaptureMouse());

    // Pointer OUTSIDE the panel: the scene must still get it, or the UI would
    // swallow every drag on the world.
    ctx.input.mouse_pos = .{ 600, 400 };
    try expect(!u.wantCaptureMouse());
}
