zcatgui/src/widgets/divider.zig
reugenio 91e13f6956 feat: zcatgui Gio parity - 12 new widgets + gesture system
New widgets (12):
- Switch: Toggle switch with animation
- IconButton: Circular icon button (filled/outlined/ghost/tonal)
- Divider: Horizontal/vertical separator with optional label
- Loader: 7 spinner styles (circular/dots/bars/pulse/bounce/ring/square)
- Surface: Elevated container with shadow layers
- Grid: Layout grid with scrolling and selection
- Resize: Draggable resize handle (horizontal/vertical/both)
- AppBar: Application bar (top/bottom) with actions
- NavDrawer: Navigation drawer with items, icons, badges
- Sheet: Side/bottom sliding panel with modal support
- Discloser: Expandable/collapsible container (3 icon styles)
- Selectable: Clickable region with selection modes

Core systems added:
- GestureRecognizer: Tap, double-tap, long-press, drag, swipe
- Velocity tracking and fling detection
- Spring physics for fluid animations

Integration:
- All widgets exported via widgets.zig
- GestureRecognizer exported via zcatgui.zig
- Spring/SpringConfig exported from animation.zig
- Color.withAlpha() method added to style.zig

Stats: 47 widget files, 338+ tests, +5,619 LOC
Full Gio UI parity achieved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:21:15 +01:00

308 lines
9.2 KiB
Zig

//! Divider Widget - Visual separator
//!
//! A simple line that separates content. Can be horizontal or vertical,
//! and optionally include a label in the middle.
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");
/// Divider orientation
pub const Orientation = enum {
horizontal,
vertical,
};
/// Divider configuration
pub const Config = struct {
/// Orientation
orientation: Orientation = .horizontal,
/// Line thickness
thickness: u16 = 1,
/// Margin on each side
margin: u16 = 8,
/// Label text (centered in divider)
label: ?[]const u8 = null,
/// Label padding (space between line and label)
label_padding: u16 = 12,
/// Inset from edges (e.g., to not span full width)
inset: u16 = 0,
/// Use dashed line
dashed: bool = false,
/// Dash length (if dashed)
dash_length: u16 = 4,
/// Gap between dashes
dash_gap: u16 = 4,
};
/// Divider colors
pub const Colors = struct {
/// Line color
line: Style.Color = Style.Color.rgba(60, 60, 60, 255),
/// Label text color
label_color: Style.Color = Style.Color.rgba(120, 120, 120, 255),
/// Label background (to cover line behind text)
label_bg: ?Style.Color = null,
pub fn fromTheme(theme: Style.Theme) Colors {
return .{
.line = theme.border,
.label_color = theme.foreground.darken(30),
.label_bg = theme.background,
};
}
};
/// Draw a simple horizontal divider
pub fn divider(ctx: *Context) void {
dividerEx(ctx, .{}, .{});
}
/// Draw a divider with label
pub fn dividerLabel(ctx: *Context, label_text: []const u8) void {
dividerEx(ctx, .{ .label = label_text }, .{});
}
/// Draw a divider with configuration
pub fn dividerEx(ctx: *Context, config: Config, colors: Colors) void {
const bounds = ctx.layout.nextRect();
dividerRect(ctx, bounds, config, colors);
}
/// Draw a divider in a specific rectangle
pub fn dividerRect(
ctx: *Context,
bounds: Layout.Rect,
config: Config,
colors: Colors,
) void {
if (bounds.isEmpty()) return;
switch (config.orientation) {
.horizontal => drawHorizontal(ctx, bounds, config, colors),
.vertical => drawVertical(ctx, bounds, config, colors),
}
}
fn drawHorizontal(ctx: *Context, bounds: Layout.Rect, config: Config, colors: Colors) void {
const y = bounds.y + @as(i32, @intCast(bounds.h / 2));
const x_start = bounds.x + @as(i32, @intCast(config.inset));
const x_end = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.inset));
const line_width = @as(u32, @intCast(@max(0, x_end - x_start)));
if (config.label) |label_text| {
if (label_text.len > 0) {
// Draw with label
const label_width = label_text.len * 8; // Approximate char width
const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const label_x = center_x - @as(i32, @intCast(label_width / 2));
const gap_start = label_x - @as(i32, @intCast(config.label_padding));
const gap_end = label_x + @as(i32, @intCast(label_width + config.label_padding));
// Left line
if (gap_start > x_start) {
drawLine(ctx, x_start, y, gap_start, config, colors);
}
// Right line
if (gap_end < x_end) {
drawLine(ctx, gap_end, y, x_end, config, colors);
}
// Label background
if (colors.label_bg) |bg| {
ctx.pushCommand(Command.rect(
gap_start,
y - 6,
@intCast(@as(u32, @intCast(gap_end - gap_start))),
12,
bg,
));
}
// Label text
ctx.pushCommand(Command.text(label_x, y - 4, label_text, colors.label_color));
return;
}
}
// Simple line without label
if (config.dashed) {
drawDashedLine(ctx, x_start, y, line_width, true, config, colors);
} else {
ctx.pushCommand(Command.rect(x_start, y, line_width, config.thickness, colors.line));
}
}
fn drawVertical(ctx: *Context, bounds: Layout.Rect, config: Config, colors: Colors) void {
const x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const y_start = bounds.y + @as(i32, @intCast(config.inset));
const y_end = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast(config.inset));
const line_height = @as(u32, @intCast(@max(0, y_end - y_start)));
if (config.label) |label_text| {
if (label_text.len > 0) {
// For vertical dividers, rotate the label concept
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
const gap_start = center_y - @as(i32, @intCast(config.label_padding));
const gap_end = center_y + @as(i32, @intCast(config.label_padding));
// Top line
if (gap_start > y_start) {
if (config.dashed) {
drawDashedLine(ctx, x, y_start, @intCast(@as(u32, @intCast(gap_start - y_start))), false, config, colors);
} else {
ctx.pushCommand(Command.rect(x, y_start, config.thickness, @intCast(@as(u32, @intCast(gap_start - y_start))), colors.line));
}
}
// Bottom line
if (gap_end < y_end) {
if (config.dashed) {
drawDashedLine(ctx, x, gap_end, @intCast(@as(u32, @intCast(y_end - gap_end))), false, config, colors);
} else {
ctx.pushCommand(Command.rect(x, gap_end, config.thickness, @intCast(@as(u32, @intCast(y_end - gap_end))), colors.line));
}
}
return;
}
}
// Simple vertical line
if (config.dashed) {
drawDashedLine(ctx, x, y_start, line_height, false, config, colors);
} else {
ctx.pushCommand(Command.rect(x, y_start, config.thickness, line_height, colors.line));
}
}
fn drawLine(ctx: *Context, x_start: i32, y: i32, x_end: i32, config: Config, colors: Colors) void {
const width = @as(u32, @intCast(@max(0, x_end - x_start)));
if (config.dashed) {
drawDashedLine(ctx, x_start, y, width, true, config, colors);
} else {
ctx.pushCommand(Command.rect(x_start, y, width, config.thickness, colors.line));
}
}
fn drawDashedLine(ctx: *Context, start_x: i32, start_y: i32, length: u32, horizontal: bool, config: Config, colors: Colors) void {
const dash_len = config.dash_length;
const gap_len = config.dash_gap;
const stride = dash_len + gap_len;
var pos: u32 = 0;
while (pos < length) {
const dash_size = @min(dash_len, length - pos);
if (horizontal) {
ctx.pushCommand(Command.rect(
start_x + @as(i32, @intCast(pos)),
start_y,
dash_size,
config.thickness,
colors.line,
));
} else {
ctx.pushCommand(Command.rect(
start_x,
start_y + @as(i32, @intCast(pos)),
config.thickness,
dash_size,
colors.line,
));
}
pos += stride;
}
}
/// Convenience: horizontal rule
pub fn hr(ctx: *Context) void {
divider(ctx);
}
/// Convenience: vertical rule
pub fn vr(ctx: *Context) void {
dividerEx(ctx, .{ .orientation = .vertical }, .{});
}
// =============================================================================
// Tests
// =============================================================================
test "divider generates command" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 16;
divider(&ctx);
try std.testing.expect(ctx.commands.items.len >= 1);
ctx.endFrame();
}
test "divider with label" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 16;
dividerLabel(&ctx, "Section");
// Should generate: left line + right line + text
try std.testing.expect(ctx.commands.items.len >= 3);
ctx.endFrame();
}
test "vertical divider" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 100;
dividerEx(&ctx, .{ .orientation = .vertical }, .{});
try std.testing.expect(ctx.commands.items.len >= 1);
ctx.endFrame();
}
test "dashed divider" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 16;
dividerEx(&ctx, .{ .dashed = true }, .{});
// Dashed line should generate multiple rect commands
try std.testing.expect(ctx.commands.items.len >= 1);
ctx.endFrame();
}
test "hr and vr convenience" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 16;
hr(&ctx);
vr(&ctx);
try std.testing.expect(ctx.commands.items.len >= 2);
ctx.endFrame();
}