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