//! Resize Widget - Draggable resize handle //! //! A handle that can be dragged to resize adjacent elements. //! Used in split panels, column resizing, etc. 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"); /// Resize direction pub const Direction = enum { /// Resize horizontally (left-right) horizontal, /// Resize vertically (up-down) vertical, /// Resize in both directions both, }; /// Resize state pub const State = struct { /// Current size (what we're controlling) size: i32 = 200, /// Is currently being dragged dragging: bool = false, /// Drag start position drag_start: i32 = 0, /// Size at drag start size_at_start: i32 = 0, pub fn init(initial_size: i32) State { return .{ .size = initial_size }; } }; /// Resize configuration pub const Config = struct { /// Resize direction direction: Direction = .horizontal, /// Handle size (width for horizontal, height for vertical) handle_size: u16 = 8, /// Minimum size constraint min_size: i32 = 50, /// Maximum size constraint (null = no limit) max_size: ?i32 = null, /// Show visual handle indicator show_handle: bool = true, /// Double-click to reset to default double_click_reset: bool = true, /// Default size for reset default_size: i32 = 200, }; /// Resize colors pub const Colors = struct { /// Handle background handle: Style.Color = Style.Color.rgba(80, 80, 80, 100), /// Handle when hovered handle_hover: Style.Color = Style.Color.rgba(100, 100, 100, 150), /// Handle when dragging handle_active: Style.Color = Style.Color.rgba(66, 133, 244, 200), /// Grip dots grip: Style.Color = Style.Color.rgb(120, 120, 120), pub fn fromTheme(theme: Style.Theme) Colors { return .{ .handle = theme.border.withAlpha(100), .handle_hover = theme.border.withAlpha(150), .handle_active = theme.primary.withAlpha(200), .grip = theme.foreground.darken(40), }; } }; /// Resize result pub const Result = struct { /// Current size value size: i32, /// Size changed this frame changed: bool, /// Delta from last frame delta: i32, /// Handle is being hovered hovered: bool, /// Handle is being dragged dragging: bool, /// Handle bounds bounds: Layout.Rect, }; /// Simple resize handle pub fn resize(ctx: *Context, state: *State) Result { return resizeEx(ctx, state, .{}, .{}); } /// Resize handle with configuration pub fn resizeEx(ctx: *Context, state: *State, config: Config, colors: Colors) Result { const bounds = ctx.layout.nextRect(); return resizeRect(ctx, bounds, state, config, colors); } /// Resize handle in specific rectangle pub fn resizeRect( ctx: *Context, bounds: Layout.Rect, state: *State, config: Config, colors: Colors, ) Result { if (bounds.isEmpty()) { return .{ .size = state.size, .changed = false, .delta = 0, .hovered = false, .dragging = false, .bounds = bounds, }; } // Calculate handle bounds based on direction const handle_bounds = switch (config.direction) { .horizontal => Layout.Rect{ .x = bounds.x + @as(i32, @intCast(bounds.w / 2)) - @as(i32, @intCast(config.handle_size / 2)), .y = bounds.y, .w = config.handle_size, .h = bounds.h, }, .vertical => Layout.Rect{ .x = bounds.x, .y = bounds.y + @as(i32, @intCast(bounds.h / 2)) - @as(i32, @intCast(config.handle_size / 2)), .w = bounds.w, .h = config.handle_size, }, .both => Layout.Rect{ .x = bounds.x + @as(i32, @intCast(bounds.w / 2)) - @as(i32, @intCast(config.handle_size / 2)), .y = bounds.y + @as(i32, @intCast(bounds.h / 2)) - @as(i32, @intCast(config.handle_size / 2)), .w = config.handle_size, .h = config.handle_size, }, }; // Mouse interaction const mouse = ctx.input.mousePos(); const hovered = handle_bounds.contains(mouse.x, mouse.y); var changed = false; var delta: i32 = 0; // Handle drag start if (hovered and ctx.input.mousePressed(.left)) { state.dragging = true; state.drag_start = switch (config.direction) { .horizontal => mouse.x, .vertical => mouse.y, .both => mouse.x, // Primary direction }; state.size_at_start = state.size; } // Handle dragging if (state.dragging) { if (ctx.input.mousePressed(.left) or ctx.input.mousePos().x != 0 or ctx.input.mousePos().y != 0) { const current_pos = switch (config.direction) { .horizontal => mouse.x, .vertical => mouse.y, .both => mouse.x, }; const drag_delta = current_pos - state.drag_start; var new_size = state.size_at_start + drag_delta; // Apply constraints new_size = @max(config.min_size, new_size); if (config.max_size) |max| { new_size = @min(max, new_size); } if (new_size != state.size) { delta = new_size - state.size; state.size = new_size; changed = true; } } // End drag if (ctx.input.mouseReleased(.left)) { state.dragging = false; } } // Draw handle if (config.show_handle) { const handle_color = if (state.dragging) colors.handle_active else if (hovered) colors.handle_hover else colors.handle; ctx.pushCommand(Command.rect( handle_bounds.x, handle_bounds.y, handle_bounds.w, handle_bounds.h, handle_color, )); // Draw grip indicator drawGrip(ctx, handle_bounds, config.direction, colors.grip); } return .{ .size = state.size, .changed = changed, .delta = delta, .hovered = hovered, .dragging = state.dragging, .bounds = handle_bounds, }; } fn drawGrip(ctx: *Context, bounds: Layout.Rect, direction: Direction, color: Style.Color) void { const dot_size: u32 = 2; const dot_spacing: i32 = 4; const dot_count: i32 = 3; const cx = bounds.x + @as(i32, @intCast(bounds.w / 2)); const cy = bounds.y + @as(i32, @intCast(bounds.h / 2)); switch (direction) { .horizontal => { // Vertical line of dots var i: i32 = -1; while (i <= 1) : (i += 1) { ctx.pushCommand(Command.rect( cx - @as(i32, @intCast(dot_size / 2)), cy + i * dot_spacing - @as(i32, @intCast(dot_size / 2)), dot_size, dot_size, color, )); } }, .vertical => { // Horizontal line of dots var i: i32 = -1; while (i <= 1) : (i += 1) { ctx.pushCommand(Command.rect( cx + i * dot_spacing - @as(i32, @intCast(dot_size / 2)), cy - @as(i32, @intCast(dot_size / 2)), dot_size, dot_size, color, )); } }, .both => { // 3x3 grid of dots var dx: i32 = -1; while (dx <= 1) : (dx += 1) { var dy: i32 = -1; while (dy <= 1) : (dy += 1) { if (dx == 0 and dy == 0) continue; // Skip center ctx.pushCommand(Command.rect( cx + dx * dot_spacing - @as(i32, @intCast(dot_size / 2)), cy + dy * dot_spacing - @as(i32, @intCast(dot_size / 2)), dot_size, dot_size, color, )); } } }, } _ = dot_count; } // ============================================================================= // Tests // ============================================================================= test "resize state init" { const state = State.init(300); try std.testing.expectEqual(@as(i32, 300), state.size); try std.testing.expect(!state.dragging); } test "resize generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(200); ctx.beginFrame(); ctx.layout.row_height = 400; const result = resize(&ctx, &state); // Should generate handle rect + grip dots try std.testing.expect(ctx.commands.items.len >= 1); try std.testing.expect(!result.changed); ctx.endFrame(); } test "resize horizontal" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(200); ctx.beginFrame(); ctx.layout.row_height = 400; _ = resizeEx(&ctx, &state, .{ .direction = .horizontal }, .{}); try std.testing.expect(ctx.commands.items.len >= 1); ctx.endFrame(); } test "resize vertical" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(200); ctx.beginFrame(); ctx.layout.row_height = 400; _ = resizeEx(&ctx, &state, .{ .direction = .vertical }, .{}); try std.testing.expect(ctx.commands.items.len >= 1); ctx.endFrame(); } test "resize constraints" { var state = State.init(200); // Test min constraint state.size = 30; const min: i32 = 50; state.size = @max(min, state.size); try std.testing.expect(state.size >= min); }