zcatgui/src/widgets/split.zig
reugenio 8adc93a345 feat: zcatgui v0.6.0 - Phase 1 Optimization Complete
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>
2025-12-09 12:45:00 +01:00

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();
}