Performance Infrastructure: - FrameArena: O(1) per-frame allocator with automatic reset - ObjectPool: Generic object pool for frequently allocated types - CommandPool: Specialized pool for draw commands - RingBuffer: Circular buffer for streaming data - ScopedArena: RAII pattern for temporary allocations Dirty Rectangle System: - Context now tracks dirty regions for partial redraws - Automatic rect merging to reduce overdraw - invalidateRect(), needsRedraw(), getDirtyRects() API - Falls back to full redraw when > 32 dirty rects Benchmark Suite: - Timer: High-resolution timing - Benchmark: Stats collection (avg, min, max, stddev, median) - FrameTimer: FPS and frame time tracking - AllocationTracker: Memory usage monitoring - Pre-built benchmarks for arena, pool, and commands Context Improvements: - Integrated FrameArena for zero-allocation hot paths - frameAllocator() for per-frame widget allocations - FrameStats for performance monitoring - Context.init() now returns error union (breaking change) New Widgets (from previous session): - Slider: Horizontal/vertical with customization - ScrollArea: Scrollable content region - Tabs: Tab container with keyboard navigation - RadioButton: Radio button groups - Menu: Dropdown menus (foundation) Theme System Expansion: - 5 built-in themes: dark, light, high_contrast, nord, dracula - ThemeManager with runtime switching - TTF font support via stb_truetype Documentation: - DEVELOPMENT_PLAN.md: 9-phase roadmap to DVUI/Gio parity - Updated WIDGET_COMPARISON.md with detailed analysis - Lego Panels architecture documented Stats: 17 widgets, 123 tests, 5 themes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
324 lines
9.8 KiB
Zig
324 lines
9.8 KiB
Zig
//! Split Widget - Resizable split panels
|
|
//!
|
|
//! HSplit and VSplit divide an area into two resizable panels.
|
|
//! The divider can be dragged with mouse or adjusted with Ctrl+arrows.
|
|
|
|
const std = @import("std");
|
|
const Context = @import("../core/context.zig").Context;
|
|
const Command = @import("../core/command.zig");
|
|
const Layout = @import("../core/layout.zig");
|
|
const Style = @import("../core/style.zig");
|
|
const Input = @import("../core/input.zig");
|
|
|
|
/// Split direction
|
|
pub const Direction = enum {
|
|
horizontal, // Left | Right
|
|
vertical, // Top / Bottom
|
|
};
|
|
|
|
/// Split state (caller-managed)
|
|
pub const SplitState = struct {
|
|
/// Split offset (0.0 to 1.0)
|
|
offset: f32 = 0.5,
|
|
/// Whether the divider is being dragged
|
|
dragging: bool = false,
|
|
/// Minimum offset (prevents panels from being too small)
|
|
min_offset: f32 = 0.1,
|
|
/// Maximum offset
|
|
max_offset: f32 = 0.9,
|
|
|
|
const Self = @This();
|
|
|
|
/// Set offset with clamping
|
|
pub fn setOffset(self: *Self, new_offset: f32) void {
|
|
self.offset = std.math.clamp(new_offset, self.min_offset, self.max_offset);
|
|
}
|
|
|
|
/// Adjust offset by delta
|
|
pub fn adjustOffset(self: *Self, delta: f32) void {
|
|
self.setOffset(self.offset + delta);
|
|
}
|
|
};
|
|
|
|
/// Split configuration
|
|
pub const SplitConfig = struct {
|
|
/// Divider thickness in pixels
|
|
divider_size: u32 = 6,
|
|
/// Whether divider is draggable
|
|
draggable: bool = true,
|
|
/// Divider color
|
|
divider_color: Style.Color = Style.Color.rgb(60, 60, 60),
|
|
/// Divider hover color
|
|
divider_hover_color: Style.Color = Style.Color.rgb(80, 80, 80),
|
|
/// Divider drag color
|
|
divider_drag_color: Style.Color = Style.Color.primary,
|
|
};
|
|
|
|
/// Result of split operation - returns the two panel rectangles
|
|
pub const SplitResult = struct {
|
|
/// First panel (left or top)
|
|
first: Layout.Rect,
|
|
/// Second panel (right or bottom)
|
|
second: Layout.Rect,
|
|
/// Divider was moved
|
|
changed: bool,
|
|
};
|
|
|
|
/// Calculate split layout without rendering
|
|
pub fn splitLayout(
|
|
bounds: Layout.Rect,
|
|
state: *const SplitState,
|
|
direction: Direction,
|
|
divider_size: u32,
|
|
) SplitResult {
|
|
if (bounds.isEmpty()) {
|
|
return .{
|
|
.first = Layout.Rect.zero(),
|
|
.second = Layout.Rect.zero(),
|
|
.changed = false,
|
|
};
|
|
}
|
|
|
|
const div_size = @as(i32, @intCast(divider_size));
|
|
|
|
return switch (direction) {
|
|
.horizontal => blk: {
|
|
const available_w = bounds.w -| divider_size;
|
|
const first_w: u32 = @intFromFloat(@as(f32, @floatFromInt(available_w)) * state.offset);
|
|
const second_w = available_w -| first_w;
|
|
|
|
break :blk .{
|
|
.first = Layout.Rect.init(bounds.x, bounds.y, first_w, bounds.h),
|
|
.second = Layout.Rect.init(
|
|
bounds.x + @as(i32, @intCast(first_w)) + div_size,
|
|
bounds.y,
|
|
second_w,
|
|
bounds.h,
|
|
),
|
|
.changed = false,
|
|
};
|
|
},
|
|
.vertical => blk: {
|
|
const available_h = bounds.h -| divider_size;
|
|
const first_h: u32 = @intFromFloat(@as(f32, @floatFromInt(available_h)) * state.offset);
|
|
const second_h = available_h -| first_h;
|
|
|
|
break :blk .{
|
|
.first = Layout.Rect.init(bounds.x, bounds.y, bounds.w, first_h),
|
|
.second = Layout.Rect.init(
|
|
bounds.x,
|
|
bounds.y + @as(i32, @intCast(first_h)) + div_size,
|
|
bounds.w,
|
|
second_h,
|
|
),
|
|
.changed = false,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Draw a horizontal split (left | right)
|
|
pub fn hsplit(
|
|
ctx: *Context,
|
|
state: *SplitState,
|
|
) SplitResult {
|
|
return hsplitEx(ctx, state, .{});
|
|
}
|
|
|
|
/// Draw a horizontal split with config
|
|
pub fn hsplitEx(
|
|
ctx: *Context,
|
|
state: *SplitState,
|
|
config: SplitConfig,
|
|
) SplitResult {
|
|
const bounds = ctx.layout.nextRect();
|
|
return splitRect(ctx, bounds, state, .horizontal, config);
|
|
}
|
|
|
|
/// Draw a vertical split (top / bottom)
|
|
pub fn vsplit(
|
|
ctx: *Context,
|
|
state: *SplitState,
|
|
) SplitResult {
|
|
return vsplitEx(ctx, state, .{});
|
|
}
|
|
|
|
/// Draw a vertical split with config
|
|
pub fn vsplitEx(
|
|
ctx: *Context,
|
|
state: *SplitState,
|
|
config: SplitConfig,
|
|
) SplitResult {
|
|
const bounds = ctx.layout.nextRect();
|
|
return splitRect(ctx, bounds, state, .vertical, config);
|
|
}
|
|
|
|
/// Draw a split in a specific rectangle
|
|
pub fn splitRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *SplitState,
|
|
direction: Direction,
|
|
config: SplitConfig,
|
|
) SplitResult {
|
|
if (bounds.isEmpty()) {
|
|
return .{
|
|
.first = Layout.Rect.zero(),
|
|
.second = Layout.Rect.zero(),
|
|
.changed = false,
|
|
};
|
|
}
|
|
|
|
var result = splitLayout(bounds, state, direction, config.divider_size);
|
|
|
|
// Calculate divider bounds
|
|
const divider = switch (direction) {
|
|
.horizontal => Layout.Rect.init(
|
|
result.first.right(),
|
|
bounds.y,
|
|
config.divider_size,
|
|
bounds.h,
|
|
),
|
|
.vertical => Layout.Rect.init(
|
|
bounds.x,
|
|
result.first.bottom(),
|
|
bounds.w,
|
|
config.divider_size,
|
|
),
|
|
};
|
|
|
|
// Check mouse interaction with divider
|
|
const mouse = ctx.input.mousePos();
|
|
const divider_hovered = divider.contains(mouse.x, mouse.y);
|
|
|
|
// Handle dragging
|
|
if (config.draggable) {
|
|
if (divider_hovered and ctx.input.mousePressed(.left)) {
|
|
state.dragging = true;
|
|
}
|
|
|
|
if (state.dragging) {
|
|
if (ctx.input.mouseDown(.left)) {
|
|
// Calculate new offset based on mouse position
|
|
const new_offset: f32 = switch (direction) {
|
|
.horizontal => blk: {
|
|
const rel_x = mouse.x - bounds.x;
|
|
break :blk @as(f32, @floatFromInt(rel_x)) / @as(f32, @floatFromInt(bounds.w));
|
|
},
|
|
.vertical => blk: {
|
|
const rel_y = mouse.y - bounds.y;
|
|
break :blk @as(f32, @floatFromInt(rel_y)) / @as(f32, @floatFromInt(bounds.h));
|
|
},
|
|
};
|
|
|
|
const old_offset = state.offset;
|
|
state.setOffset(new_offset);
|
|
|
|
if (state.offset != old_offset) {
|
|
result.changed = true;
|
|
// Recalculate layout
|
|
result = splitLayout(bounds, state, direction, config.divider_size);
|
|
}
|
|
} else {
|
|
state.dragging = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw divider
|
|
const divider_color = if (state.dragging)
|
|
config.divider_drag_color
|
|
else if (divider_hovered)
|
|
config.divider_hover_color
|
|
else
|
|
config.divider_color;
|
|
|
|
ctx.pushCommand(Command.rect(divider.x, divider.y, divider.w, divider.h, divider_color));
|
|
|
|
// Draw grip lines on divider
|
|
const grip_color = divider_color.lighten(20);
|
|
const num_grips: u32 = 3;
|
|
const grip_spacing: u32 = 4;
|
|
|
|
switch (direction) {
|
|
.horizontal => {
|
|
const grip_h: u32 = 20;
|
|
const grip_y = divider.y + @as(i32, @intCast((divider.h -| grip_h) / 2));
|
|
const grip_x = divider.x + @as(i32, @intCast(config.divider_size / 2));
|
|
|
|
for (0..num_grips) |i| {
|
|
const y = grip_y + @as(i32, @intCast(i * grip_spacing + grip_spacing));
|
|
ctx.pushCommand(Command.line(grip_x - 1, y, grip_x + 1, y, grip_color));
|
|
}
|
|
},
|
|
.vertical => {
|
|
const grip_w: u32 = 20;
|
|
const grip_x = divider.x + @as(i32, @intCast((divider.w -| grip_w) / 2));
|
|
const grip_y = divider.y + @as(i32, @intCast(config.divider_size / 2));
|
|
|
|
for (0..num_grips) |i| {
|
|
const x = grip_x + @as(i32, @intCast(i * grip_spacing + grip_spacing));
|
|
ctx.pushCommand(Command.line(x, grip_y - 1, x, grip_y + 1, grip_color));
|
|
}
|
|
},
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "SplitState offset clamping" {
|
|
var state = SplitState{};
|
|
|
|
state.setOffset(0.7);
|
|
try std.testing.expectApproxEqAbs(@as(f32, 0.7), state.offset, 0.001);
|
|
|
|
state.setOffset(0.05); // Below min
|
|
try std.testing.expectApproxEqAbs(@as(f32, 0.1), state.offset, 0.001);
|
|
|
|
state.setOffset(0.95); // Above max
|
|
try std.testing.expectApproxEqAbs(@as(f32, 0.9), state.offset, 0.001);
|
|
}
|
|
|
|
test "splitLayout horizontal" {
|
|
const state = SplitState{ .offset = 0.5 };
|
|
const bounds = Layout.Rect.init(0, 0, 206, 100); // 206 = 200 + 6 divider
|
|
|
|
const result = splitLayout(bounds, &state, .horizontal, 6);
|
|
|
|
try std.testing.expectEqual(@as(u32, 100), result.first.w);
|
|
try std.testing.expectEqual(@as(u32, 100), result.second.w);
|
|
try std.testing.expectEqual(@as(i32, 106), result.second.x); // 100 + 6
|
|
}
|
|
|
|
test "splitLayout vertical" {
|
|
const state = SplitState{ .offset = 0.5 };
|
|
const bounds = Layout.Rect.init(0, 0, 100, 206);
|
|
|
|
const result = splitLayout(bounds, &state, .vertical, 6);
|
|
|
|
try std.testing.expectEqual(@as(u32, 100), result.first.h);
|
|
try std.testing.expectEqual(@as(u32, 100), result.second.h);
|
|
try std.testing.expectEqual(@as(i32, 106), result.second.y);
|
|
}
|
|
|
|
test "hsplit generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = SplitState{};
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 400;
|
|
|
|
const result = hsplit(&ctx, &state);
|
|
|
|
try std.testing.expect(result.first.w > 0);
|
|
try std.testing.expect(result.second.w > 0);
|
|
try std.testing.expect(ctx.commands.items.len >= 1); // At least divider
|
|
|
|
ctx.endFrame();
|
|
}
|