feat: zcatgui v0.8.0 - Phase 2 Complete (6 new widgets)
New Widgets: - TextArea: Multi-line text editor with cursor navigation, line numbers, selection, and scrolling support - Tree: Hierarchical tree view with expand/collapse, keyboard navigation, and selection - Badge: Status labels with variants (primary, success, warning, danger, info, outline), dismissible option - TagGroup: Multiple badges in a row with wrapping From previous session (v0.7.0): - Progress: Bar (solid, striped, gradient, segmented), Circle, Spinner (circular, dots, bars, ring) - Tooltip: Hover tooltips with smart positioning - Toast: Non-blocking notifications with auto-dismiss Widget count: 23 widgets Test count: 163 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1ae0199db7
commit
8044c1df43
4 changed files with 1870 additions and 0 deletions
502
src/widgets/badge.zig
Normal file
502
src/widgets/badge.zig
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
//! Badge Widget - Labels and tags
|
||||||
|
//!
|
||||||
|
//! Colored badges/tags for status indicators, labels, and counts.
|
||||||
|
//! Supports different variants, sizes, and optional dismiss buttons.
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// Badge variant
|
||||||
|
pub const Variant = enum {
|
||||||
|
/// Default style
|
||||||
|
default,
|
||||||
|
/// Primary/accent color
|
||||||
|
primary,
|
||||||
|
/// Success/positive
|
||||||
|
success,
|
||||||
|
/// Warning/caution
|
||||||
|
warning,
|
||||||
|
/// Error/danger
|
||||||
|
danger,
|
||||||
|
/// Info/neutral
|
||||||
|
info,
|
||||||
|
/// Outline style (border only)
|
||||||
|
outline,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Badge size
|
||||||
|
pub const Size = enum {
|
||||||
|
small,
|
||||||
|
medium,
|
||||||
|
large,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Badge configuration
|
||||||
|
pub const Config = struct {
|
||||||
|
/// Visual variant
|
||||||
|
variant: Variant = .default,
|
||||||
|
/// Size
|
||||||
|
size: Size = .medium,
|
||||||
|
/// Show dismiss/close button
|
||||||
|
dismissible: bool = false,
|
||||||
|
/// Pill shape (fully rounded)
|
||||||
|
pill: bool = false,
|
||||||
|
/// Optional icon (single character)
|
||||||
|
icon: ?u8 = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Badge colors
|
||||||
|
pub const Colors = struct {
|
||||||
|
/// Colors for each variant
|
||||||
|
default_bg: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||||
|
default_fg: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||||
|
primary_bg: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
||||||
|
primary_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
success_bg: Style.Color = Style.Color.rgba(46, 160, 67, 255),
|
||||||
|
success_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
warning_bg: Style.Color = Style.Color.rgba(210, 153, 34, 255),
|
||||||
|
warning_fg: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
||||||
|
danger_bg: Style.Color = Style.Color.rgba(207, 34, 46, 255),
|
||||||
|
danger_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
info_bg: Style.Color = Style.Color.rgba(56, 139, 253, 255),
|
||||||
|
info_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
outline_bg: Style.Color = Style.Color.rgba(0, 0, 0, 0),
|
||||||
|
outline_fg: Style.Color = Style.Color.rgba(180, 180, 180, 255),
|
||||||
|
outline_border: Style.Color = Style.Color.rgba(150, 150, 150, 255),
|
||||||
|
|
||||||
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
||||||
|
return .{
|
||||||
|
.default_bg = theme.secondary,
|
||||||
|
.default_fg = theme.foreground,
|
||||||
|
.primary_bg = theme.primary,
|
||||||
|
.primary_fg = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
.success_bg = theme.success,
|
||||||
|
.success_fg = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
.warning_bg = theme.warning,
|
||||||
|
.warning_fg = Style.Color.rgba(30, 30, 30, 255),
|
||||||
|
.danger_bg = theme.error_color,
|
||||||
|
.danger_fg = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
.info_bg = theme.info,
|
||||||
|
.info_fg = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
.outline_bg = Style.Color.rgba(0, 0, 0, 0),
|
||||||
|
.outline_fg = theme.foreground,
|
||||||
|
.outline_border = theme.border,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get background color for variant
|
||||||
|
pub fn getBg(self: Colors, variant: Variant) Style.Color {
|
||||||
|
return switch (variant) {
|
||||||
|
.default => self.default_bg,
|
||||||
|
.primary => self.primary_bg,
|
||||||
|
.success => self.success_bg,
|
||||||
|
.warning => self.warning_bg,
|
||||||
|
.danger => self.danger_bg,
|
||||||
|
.info => self.info_bg,
|
||||||
|
.outline => self.outline_bg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get foreground color for variant
|
||||||
|
pub fn getFg(self: Colors, variant: Variant) Style.Color {
|
||||||
|
return switch (variant) {
|
||||||
|
.default => self.default_fg,
|
||||||
|
.primary => self.primary_fg,
|
||||||
|
.success => self.success_fg,
|
||||||
|
.warning => self.warning_fg,
|
||||||
|
.danger => self.danger_fg,
|
||||||
|
.info => self.info_fg,
|
||||||
|
.outline => self.outline_fg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of badge widget
|
||||||
|
pub const Result = struct {
|
||||||
|
/// Badge was clicked
|
||||||
|
clicked: bool = false,
|
||||||
|
/// Dismiss button was clicked
|
||||||
|
dismissed: bool = false,
|
||||||
|
/// Badge bounds (for positioning related elements)
|
||||||
|
bounds: Layout.Rect = Layout.Rect.zero(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Draw a badge and return interaction result
|
||||||
|
pub fn badge(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeEx(ctx, text, .{}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a badge with specific variant
|
||||||
|
pub fn badgeVariant(ctx: *Context, text: []const u8, variant: Variant) Result {
|
||||||
|
return badgeEx(ctx, text, .{ .variant = variant }, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a badge with custom configuration
|
||||||
|
pub fn badgeEx(ctx: *Context, text: []const u8, config: Config, colors: Colors) Result {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return badgeRect(ctx, bounds, text, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a badge in a specific rectangle
|
||||||
|
pub fn badgeRect(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
text: []const u8,
|
||||||
|
config: Config,
|
||||||
|
colors: Colors,
|
||||||
|
) Result {
|
||||||
|
var result = Result{
|
||||||
|
.bounds = bounds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bounds.isEmpty()) return result;
|
||||||
|
|
||||||
|
// Check mouse interaction
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
const hovered = bounds.contains(mouse.x, mouse.y);
|
||||||
|
const clicked = hovered and ctx.input.mousePressed(.left);
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
result.clicked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get size parameters
|
||||||
|
const params = getSizeParams(config.size);
|
||||||
|
const padding_h = params.padding_h;
|
||||||
|
const padding_v = params.padding_v;
|
||||||
|
const char_height: u32 = 8;
|
||||||
|
|
||||||
|
// Calculate text width
|
||||||
|
const char_width: u32 = 8;
|
||||||
|
var content_width: u32 = @intCast(text.len * char_width);
|
||||||
|
|
||||||
|
// Add icon width
|
||||||
|
if (config.icon != null) {
|
||||||
|
content_width += char_width + 4; // icon + spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dismiss button width
|
||||||
|
const dismiss_width: u32 = if (config.dismissible) char_width + 4 else 0;
|
||||||
|
content_width += dismiss_width;
|
||||||
|
|
||||||
|
// Calculate badge dimensions
|
||||||
|
const badge_width = @min(content_width + padding_h * 2, bounds.w);
|
||||||
|
const badge_height = @min(char_height + padding_v * 2, bounds.h);
|
||||||
|
|
||||||
|
// Get colors
|
||||||
|
const bg_color = if (hovered)
|
||||||
|
colors.getBg(config.variant).lighten(10)
|
||||||
|
else
|
||||||
|
colors.getBg(config.variant);
|
||||||
|
const fg_color = colors.getFg(config.variant);
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, badge_width, badge_height, bg_color));
|
||||||
|
|
||||||
|
// Draw border for outline variant
|
||||||
|
if (config.variant == .outline) {
|
||||||
|
ctx.pushCommand(Command.rectOutline(
|
||||||
|
bounds.x,
|
||||||
|
bounds.y,
|
||||||
|
badge_width,
|
||||||
|
badge_height,
|
||||||
|
colors.outline_border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate text position
|
||||||
|
var text_x = bounds.x + @as(i32, @intCast(padding_h));
|
||||||
|
const text_y = bounds.y + @as(i32, @intCast(padding_v));
|
||||||
|
|
||||||
|
// Draw icon
|
||||||
|
if (config.icon) |icon| {
|
||||||
|
const icon_str = &[_]u8{icon};
|
||||||
|
ctx.pushCommand(Command.text(text_x, text_y, icon_str, fg_color));
|
||||||
|
text_x += @as(i32, @intCast(char_width + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
ctx.pushCommand(Command.text(text_x, text_y, text, fg_color));
|
||||||
|
|
||||||
|
// Draw dismiss button
|
||||||
|
if (config.dismissible) {
|
||||||
|
const dismiss_x = bounds.x + @as(i32, @intCast(badge_width - padding_h - char_width));
|
||||||
|
ctx.pushCommand(Command.text(dismiss_x, text_y, "x", fg_color.darken(20)));
|
||||||
|
|
||||||
|
// Check if dismiss button was clicked
|
||||||
|
if (clicked) {
|
||||||
|
const dismiss_bounds = Layout.Rect.init(
|
||||||
|
dismiss_x,
|
||||||
|
bounds.y,
|
||||||
|
char_width + padding_h,
|
||||||
|
badge_height,
|
||||||
|
);
|
||||||
|
if (dismiss_bounds.contains(mouse.x, mouse.y)) {
|
||||||
|
result.dismissed = true;
|
||||||
|
result.clicked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update result bounds with actual size
|
||||||
|
result.bounds = Layout.Rect.init(bounds.x, bounds.y, badge_width, badge_height);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size parameters
|
||||||
|
const SizeParams = struct {
|
||||||
|
padding_h: u32,
|
||||||
|
padding_v: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn getSizeParams(size: Size) SizeParams {
|
||||||
|
return switch (size) {
|
||||||
|
.small => .{ .padding_h = 4, .padding_v = 2 },
|
||||||
|
.medium => .{ .padding_h = 8, .padding_v = 4 },
|
||||||
|
.large => .{ .padding_h = 12, .padding_v = 6 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tag Group - Multiple tags in a row
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Tag definition for tag groups
|
||||||
|
pub const Tag = struct {
|
||||||
|
text: []const u8,
|
||||||
|
variant: Variant = .default,
|
||||||
|
icon: ?u8 = null,
|
||||||
|
dismissible: bool = false,
|
||||||
|
user_data: ?*anyopaque = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tag group configuration
|
||||||
|
pub const TagGroupConfig = struct {
|
||||||
|
/// Spacing between tags
|
||||||
|
spacing: u32 = 4,
|
||||||
|
/// Size for all tags
|
||||||
|
size: Size = .medium,
|
||||||
|
/// Allow wrapping to multiple lines
|
||||||
|
wrap: bool = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of tag group
|
||||||
|
pub const TagGroupResult = struct {
|
||||||
|
/// Index of clicked tag (if any)
|
||||||
|
clicked: ?usize = null,
|
||||||
|
/// Index of dismissed tag (if any)
|
||||||
|
dismissed: ?usize = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Draw a group of tags
|
||||||
|
pub fn tagGroup(ctx: *Context, tags: []const Tag) TagGroupResult {
|
||||||
|
return tagGroupEx(ctx, tags, .{}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a group of tags with custom configuration
|
||||||
|
pub fn tagGroupEx(
|
||||||
|
ctx: *Context,
|
||||||
|
tags: []const Tag,
|
||||||
|
config: TagGroupConfig,
|
||||||
|
colors: Colors,
|
||||||
|
) TagGroupResult {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return tagGroupRect(ctx, bounds, tags, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a group of tags in a specific rectangle
|
||||||
|
pub fn tagGroupRect(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
tags: []const Tag,
|
||||||
|
config: TagGroupConfig,
|
||||||
|
colors: Colors,
|
||||||
|
) TagGroupResult {
|
||||||
|
var result = TagGroupResult{};
|
||||||
|
|
||||||
|
if (bounds.isEmpty() or tags.len == 0) return result;
|
||||||
|
|
||||||
|
const char_width: u32 = 8;
|
||||||
|
const char_height: u32 = 8;
|
||||||
|
const params = getSizeParams(config.size);
|
||||||
|
|
||||||
|
var x = bounds.x;
|
||||||
|
var y = bounds.y;
|
||||||
|
const tag_height = char_height + params.padding_v * 2;
|
||||||
|
|
||||||
|
for (tags, 0..) |tag, i| {
|
||||||
|
// Calculate tag width
|
||||||
|
var tag_width: u32 = @intCast(tag.text.len * char_width);
|
||||||
|
tag_width += params.padding_h * 2;
|
||||||
|
if (tag.icon != null) tag_width += char_width + 4;
|
||||||
|
if (tag.dismissible) tag_width += char_width + 4;
|
||||||
|
|
||||||
|
// Check if we need to wrap
|
||||||
|
if (config.wrap and x > bounds.x and
|
||||||
|
@as(u32, @intCast(x - bounds.x)) + tag_width > bounds.w)
|
||||||
|
{
|
||||||
|
x = bounds.x;
|
||||||
|
y += @as(i32, @intCast(tag_height + config.spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're still within bounds
|
||||||
|
if (y - bounds.y + @as(i32, @intCast(tag_height)) > @as(i32, @intCast(bounds.h))) break;
|
||||||
|
|
||||||
|
// Draw tag
|
||||||
|
const tag_bounds = Layout.Rect.init(x, y, tag_width, tag_height);
|
||||||
|
const tag_result = badgeRect(ctx, tag_bounds, tag.text, .{
|
||||||
|
.variant = tag.variant,
|
||||||
|
.size = config.size,
|
||||||
|
.dismissible = tag.dismissible,
|
||||||
|
.icon = tag.icon,
|
||||||
|
}, colors);
|
||||||
|
|
||||||
|
if (tag_result.clicked) {
|
||||||
|
result.clicked = i;
|
||||||
|
}
|
||||||
|
if (tag_result.dismissed) {
|
||||||
|
result.dismissed = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
x += @as(i32, @intCast(tag_width + config.spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Convenience constructors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Create a primary badge
|
||||||
|
pub fn primary(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a success badge
|
||||||
|
pub fn success(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a warning badge
|
||||||
|
pub fn warning(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a danger badge
|
||||||
|
pub fn danger(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an info badge
|
||||||
|
pub fn info(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an outline badge
|
||||||
|
pub fn outline(ctx: *Context, text: []const u8) Result {
|
||||||
|
return badgeVariant(ctx, text, .outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "badge generates commands" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 24;
|
||||||
|
|
||||||
|
_ = badge(&ctx, "Test");
|
||||||
|
|
||||||
|
// Should generate: rect (bg) + text
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "badge variants" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 24;
|
||||||
|
|
||||||
|
_ = primary(&ctx, "Primary");
|
||||||
|
_ = success(&ctx, "Success");
|
||||||
|
_ = warning(&ctx, "Warning");
|
||||||
|
_ = danger(&ctx, "Danger");
|
||||||
|
_ = info(&ctx, "Info");
|
||||||
|
_ = outline(&ctx, "Outline");
|
||||||
|
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 12);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "badge with icon" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 24;
|
||||||
|
|
||||||
|
_ = badgeEx(&ctx, "Status", .{ .icon = '*' }, .{});
|
||||||
|
|
||||||
|
// Should have icon text + label text
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 3);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "badge dismissible" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 24;
|
||||||
|
|
||||||
|
_ = badgeEx(&ctx, "Tag", .{ .dismissible = true }, .{});
|
||||||
|
|
||||||
|
// Should have bg + text + dismiss x
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 3);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "tagGroup" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 100;
|
||||||
|
|
||||||
|
const tags = [_]Tag{
|
||||||
|
.{ .text = "Tag1", .variant = .primary },
|
||||||
|
.{ .text = "Tag2", .variant = .success },
|
||||||
|
.{ .text = "Tag3", .variant = .warning },
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = tagGroup(&ctx, &tags);
|
||||||
|
|
||||||
|
// Should have commands for all 3 tags
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 6);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Colors.getBg/getFg" {
|
||||||
|
const colors = Colors{};
|
||||||
|
|
||||||
|
try std.testing.expectEqual(colors.primary_bg, colors.getBg(.primary));
|
||||||
|
try std.testing.expectEqual(colors.success_bg, colors.getBg(.success));
|
||||||
|
try std.testing.expectEqual(colors.primary_fg, colors.getFg(.primary));
|
||||||
|
try std.testing.expectEqual(colors.success_fg, colors.getFg(.success));
|
||||||
|
}
|
||||||
871
src/widgets/textarea.zig
Normal file
871
src/widgets/textarea.zig
Normal file
|
|
@ -0,0 +1,871 @@
|
||||||
|
//! TextArea Widget - Multi-line text editor
|
||||||
|
//!
|
||||||
|
//! A multi-line text input with cursor navigation, selection, and scrolling.
|
||||||
|
//! Supports line wrapping and handles large documents efficiently.
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// Text area state (caller-managed)
|
||||||
|
pub const TextAreaState = struct {
|
||||||
|
/// Text buffer
|
||||||
|
buffer: []u8,
|
||||||
|
/// Current text length
|
||||||
|
len: usize = 0,
|
||||||
|
/// Cursor position (byte index)
|
||||||
|
cursor: usize = 0,
|
||||||
|
/// Selection start (byte index), null if no selection
|
||||||
|
selection_start: ?usize = null,
|
||||||
|
/// Scroll offset (line number)
|
||||||
|
scroll_y: usize = 0,
|
||||||
|
/// Horizontal scroll offset (chars)
|
||||||
|
scroll_x: usize = 0,
|
||||||
|
/// Whether this input has focus
|
||||||
|
focused: bool = false,
|
||||||
|
|
||||||
|
/// Initialize with empty buffer
|
||||||
|
pub fn init(buffer: []u8) TextAreaState {
|
||||||
|
return .{ .buffer = buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current text
|
||||||
|
pub fn text(self: TextAreaState) []const u8 {
|
||||||
|
return self.buffer[0..self.len];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set text programmatically
|
||||||
|
pub fn setText(self: *TextAreaState, new_text: []const u8) void {
|
||||||
|
const copy_len = @min(new_text.len, self.buffer.len);
|
||||||
|
@memcpy(self.buffer[0..copy_len], new_text[0..copy_len]);
|
||||||
|
self.len = copy_len;
|
||||||
|
self.cursor = copy_len;
|
||||||
|
self.selection_start = null;
|
||||||
|
self.scroll_y = 0;
|
||||||
|
self.scroll_x = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the text
|
||||||
|
pub fn clear(self: *TextAreaState) void {
|
||||||
|
self.len = 0;
|
||||||
|
self.cursor = 0;
|
||||||
|
self.selection_start = null;
|
||||||
|
self.scroll_y = 0;
|
||||||
|
self.scroll_x = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert text at cursor
|
||||||
|
pub fn insert(self: *TextAreaState, new_text: []const u8) void {
|
||||||
|
// Delete selection first if any
|
||||||
|
self.deleteSelection();
|
||||||
|
|
||||||
|
const available = self.buffer.len - self.len;
|
||||||
|
const to_insert = @min(new_text.len, available);
|
||||||
|
|
||||||
|
if (to_insert == 0) return;
|
||||||
|
|
||||||
|
// Move text after cursor
|
||||||
|
const after_cursor = self.len - self.cursor;
|
||||||
|
if (after_cursor > 0) {
|
||||||
|
std.mem.copyBackwards(
|
||||||
|
u8,
|
||||||
|
self.buffer[self.cursor + to_insert .. self.len + to_insert],
|
||||||
|
self.buffer[self.cursor..self.len],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new text
|
||||||
|
@memcpy(self.buffer[self.cursor..][0..to_insert], new_text[0..to_insert]);
|
||||||
|
self.len += to_insert;
|
||||||
|
self.cursor += to_insert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a newline
|
||||||
|
pub fn insertNewline(self: *TextAreaState) void {
|
||||||
|
self.insert("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character before cursor (backspace)
|
||||||
|
pub fn deleteBack(self: *TextAreaState) void {
|
||||||
|
if (self.selection_start != null) {
|
||||||
|
self.deleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.cursor == 0) return;
|
||||||
|
|
||||||
|
// Move text after cursor back
|
||||||
|
const after_cursor = self.len - self.cursor;
|
||||||
|
if (after_cursor > 0) {
|
||||||
|
std.mem.copyForwards(
|
||||||
|
u8,
|
||||||
|
self.buffer[self.cursor - 1 .. self.len - 1],
|
||||||
|
self.buffer[self.cursor..self.len],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cursor -= 1;
|
||||||
|
self.len -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character at cursor (delete key)
|
||||||
|
pub fn deleteForward(self: *TextAreaState) void {
|
||||||
|
if (self.selection_start != null) {
|
||||||
|
self.deleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.cursor >= self.len) return;
|
||||||
|
|
||||||
|
// Move text after cursor back
|
||||||
|
const after_cursor = self.len - self.cursor - 1;
|
||||||
|
if (after_cursor > 0) {
|
||||||
|
std.mem.copyForwards(
|
||||||
|
u8,
|
||||||
|
self.buffer[self.cursor .. self.len - 1],
|
||||||
|
self.buffer[self.cursor + 1 .. self.len],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.len -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete selected text
|
||||||
|
fn deleteSelection(self: *TextAreaState) void {
|
||||||
|
const start = self.selection_start orelse return;
|
||||||
|
const sel_start = @min(start, self.cursor);
|
||||||
|
const sel_end = @max(start, self.cursor);
|
||||||
|
const sel_len = sel_end - sel_start;
|
||||||
|
|
||||||
|
if (sel_len == 0) {
|
||||||
|
self.selection_start = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move text after selection
|
||||||
|
const after_sel = self.len - sel_end;
|
||||||
|
if (after_sel > 0) {
|
||||||
|
std.mem.copyForwards(
|
||||||
|
u8,
|
||||||
|
self.buffer[sel_start .. sel_start + after_sel],
|
||||||
|
self.buffer[sel_end..self.len],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.len -= sel_len;
|
||||||
|
self.cursor = sel_start;
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cursor line and column
|
||||||
|
pub fn getCursorPosition(self: TextAreaState) struct { line: usize, col: usize } {
|
||||||
|
var line: usize = 0;
|
||||||
|
var col: usize = 0;
|
||||||
|
var i: usize = 0;
|
||||||
|
|
||||||
|
while (i < self.cursor and i < self.len) : (i += 1) {
|
||||||
|
if (self.buffer[i] == '\n') {
|
||||||
|
line += 1;
|
||||||
|
col = 0;
|
||||||
|
} else {
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ .line = line, .col = col };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get byte offset for line start
|
||||||
|
fn getLineStart(self: TextAreaState, line: usize) usize {
|
||||||
|
if (line == 0) return 0;
|
||||||
|
|
||||||
|
var current_line: usize = 0;
|
||||||
|
var i: usize = 0;
|
||||||
|
|
||||||
|
while (i < self.len) : (i += 1) {
|
||||||
|
if (self.buffer[i] == '\n') {
|
||||||
|
current_line += 1;
|
||||||
|
if (current_line == line) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get byte offset for line end (before newline)
|
||||||
|
fn getLineEnd(self: TextAreaState, line: usize) usize {
|
||||||
|
const line_start = self.getLineStart(line);
|
||||||
|
var i = line_start;
|
||||||
|
|
||||||
|
while (i < self.len) : (i += 1) {
|
||||||
|
if (self.buffer[i] == '\n') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count total lines
|
||||||
|
pub fn lineCount(self: TextAreaState) usize {
|
||||||
|
var count: usize = 1;
|
||||||
|
for (self.buffer[0..self.len]) |c| {
|
||||||
|
if (c == '\n') count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor left
|
||||||
|
pub fn cursorLeft(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.cursor > 0) {
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor right
|
||||||
|
pub fn cursorRight(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.cursor < self.len) {
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor up one line
|
||||||
|
pub fn cursorUp(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
if (pos.line == 0) {
|
||||||
|
// Already on first line, go to start
|
||||||
|
self.cursor = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to previous line, same column if possible
|
||||||
|
const prev_line_start = self.getLineStart(pos.line - 1);
|
||||||
|
const prev_line_end = self.getLineEnd(pos.line - 1);
|
||||||
|
const prev_line_len = prev_line_end - prev_line_start;
|
||||||
|
|
||||||
|
self.cursor = prev_line_start + @min(pos.col, prev_line_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor down one line
|
||||||
|
pub fn cursorDown(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
const total_lines = self.lineCount();
|
||||||
|
|
||||||
|
if (pos.line >= total_lines - 1) {
|
||||||
|
// Already on last line, go to end
|
||||||
|
self.cursor = self.len;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next line, same column if possible
|
||||||
|
const next_line_start = self.getLineStart(pos.line + 1);
|
||||||
|
const next_line_end = self.getLineEnd(pos.line + 1);
|
||||||
|
const next_line_len = next_line_end - next_line_start;
|
||||||
|
|
||||||
|
self.cursor = next_line_start + @min(pos.col, next_line_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor to start of line
|
||||||
|
pub fn cursorHome(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
self.cursor = self.getLineStart(pos.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor to end of line
|
||||||
|
pub fn cursorEnd(self: *TextAreaState, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
self.cursor = self.getLineEnd(pos.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor up one page
|
||||||
|
pub fn pageUp(self: *TextAreaState, visible_lines: usize, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
const lines_to_move = @min(pos.line, visible_lines);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < lines_to_move) : (i += 1) {
|
||||||
|
const save_sel = self.selection_start;
|
||||||
|
self.cursorUp(false);
|
||||||
|
self.selection_start = save_sel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor down one page
|
||||||
|
pub fn pageDown(self: *TextAreaState, visible_lines: usize, shift: bool) void {
|
||||||
|
if (shift and self.selection_start == null) {
|
||||||
|
self.selection_start = self.cursor;
|
||||||
|
} else if (!shift) {
|
||||||
|
self.selection_start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
const total_lines = self.lineCount();
|
||||||
|
const lines_to_move = @min(total_lines - 1 - pos.line, visible_lines);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < lines_to_move) : (i += 1) {
|
||||||
|
const save_sel = self.selection_start;
|
||||||
|
self.cursorDown(false);
|
||||||
|
self.selection_start = save_sel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all text
|
||||||
|
pub fn selectAll(self: *TextAreaState) void {
|
||||||
|
self.selection_start = 0;
|
||||||
|
self.cursor = self.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure cursor is visible by adjusting scroll
|
||||||
|
pub fn ensureCursorVisible(self: *TextAreaState, visible_lines: usize, visible_cols: usize) void {
|
||||||
|
const pos = self.getCursorPosition();
|
||||||
|
|
||||||
|
// Vertical scroll
|
||||||
|
if (pos.line < self.scroll_y) {
|
||||||
|
self.scroll_y = pos.line;
|
||||||
|
} else if (pos.line >= self.scroll_y + visible_lines) {
|
||||||
|
self.scroll_y = pos.line - visible_lines + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal scroll
|
||||||
|
if (pos.col < self.scroll_x) {
|
||||||
|
self.scroll_x = pos.col;
|
||||||
|
} else if (pos.col >= self.scroll_x + visible_cols) {
|
||||||
|
self.scroll_x = pos.col - visible_cols + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Text area configuration
|
||||||
|
pub const TextAreaConfig = struct {
|
||||||
|
/// Placeholder text when empty
|
||||||
|
placeholder: []const u8 = "",
|
||||||
|
/// Read-only mode
|
||||||
|
readonly: bool = false,
|
||||||
|
/// Show line numbers
|
||||||
|
line_numbers: bool = false,
|
||||||
|
/// Word wrap
|
||||||
|
word_wrap: bool = false,
|
||||||
|
/// Tab size in spaces
|
||||||
|
tab_size: u8 = 4,
|
||||||
|
/// Padding inside the text area
|
||||||
|
padding: u32 = 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Text area colors
|
||||||
|
pub const TextAreaColors = struct {
|
||||||
|
background: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
||||||
|
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||||
|
placeholder: Style.Color = Style.Color.rgba(128, 128, 128, 255),
|
||||||
|
cursor: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
selection: Style.Color = Style.Color.rgba(50, 100, 150, 180),
|
||||||
|
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||||
|
border_focused: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
||||||
|
line_numbers_bg: Style.Color = Style.Color.rgba(40, 40, 40, 255),
|
||||||
|
line_numbers_fg: Style.Color = Style.Color.rgba(128, 128, 128, 255),
|
||||||
|
|
||||||
|
pub fn fromTheme(theme: Style.Theme) TextAreaColors {
|
||||||
|
return .{
|
||||||
|
.background = theme.input_bg,
|
||||||
|
.text = theme.input_fg,
|
||||||
|
.placeholder = theme.secondary,
|
||||||
|
.cursor = theme.foreground,
|
||||||
|
.selection = theme.selection_bg,
|
||||||
|
.border = theme.input_border,
|
||||||
|
.border_focused = theme.primary,
|
||||||
|
.line_numbers_bg = theme.background.darken(10),
|
||||||
|
.line_numbers_fg = theme.secondary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of text area widget
|
||||||
|
pub const TextAreaResult = struct {
|
||||||
|
/// Text was changed this frame
|
||||||
|
changed: bool,
|
||||||
|
/// Widget was clicked (for focus management)
|
||||||
|
clicked: bool,
|
||||||
|
/// Current cursor position
|
||||||
|
cursor_line: usize,
|
||||||
|
cursor_col: usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Draw a text area and return interaction result
|
||||||
|
pub fn textArea(ctx: *Context, state: *TextAreaState) TextAreaResult {
|
||||||
|
return textAreaEx(ctx, state, .{}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a text area with custom configuration
|
||||||
|
pub fn textAreaEx(
|
||||||
|
ctx: *Context,
|
||||||
|
state: *TextAreaState,
|
||||||
|
config: TextAreaConfig,
|
||||||
|
colors: TextAreaColors,
|
||||||
|
) TextAreaResult {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return textAreaRect(ctx, bounds, state, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a text area in a specific rectangle
|
||||||
|
pub fn textAreaRect(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
state: *TextAreaState,
|
||||||
|
config: TextAreaConfig,
|
||||||
|
colors: TextAreaColors,
|
||||||
|
) TextAreaResult {
|
||||||
|
var result = TextAreaResult{
|
||||||
|
.changed = false,
|
||||||
|
.clicked = false,
|
||||||
|
.cursor_line = 0,
|
||||||
|
.cursor_col = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bounds.isEmpty()) return result;
|
||||||
|
|
||||||
|
// Check mouse interaction
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
const hovered = bounds.contains(mouse.x, mouse.y);
|
||||||
|
const clicked = hovered and ctx.input.mousePressed(.left);
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
state.focused = true;
|
||||||
|
result.clicked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get colors
|
||||||
|
const bg_color = if (state.focused) colors.background.lighten(5) else colors.background;
|
||||||
|
const border_color = if (state.focused) colors.border_focused else colors.border;
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const char_width: u32 = 8;
|
||||||
|
const char_height: u32 = 8;
|
||||||
|
const line_height: u32 = char_height + 2;
|
||||||
|
|
||||||
|
// Line numbers width
|
||||||
|
const line_num_width: u32 = if (config.line_numbers)
|
||||||
|
@as(u32, @intCast(countDigits(state.lineCount()))) * char_width + 8
|
||||||
|
else
|
||||||
|
0;
|
||||||
|
|
||||||
|
// Inner area for text
|
||||||
|
var text_area = bounds.shrink(config.padding);
|
||||||
|
if (text_area.isEmpty()) return result;
|
||||||
|
|
||||||
|
// Draw line numbers gutter
|
||||||
|
if (config.line_numbers and line_num_width > 0) {
|
||||||
|
ctx.pushCommand(Command.rect(
|
||||||
|
text_area.x,
|
||||||
|
text_area.y,
|
||||||
|
line_num_width,
|
||||||
|
text_area.h,
|
||||||
|
colors.line_numbers_bg,
|
||||||
|
));
|
||||||
|
// Adjust text area to exclude gutter
|
||||||
|
text_area = Layout.Rect.init(
|
||||||
|
text_area.x + @as(i32, @intCast(line_num_width)),
|
||||||
|
text_area.y,
|
||||||
|
text_area.w -| line_num_width,
|
||||||
|
text_area.h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text_area.isEmpty()) return result;
|
||||||
|
|
||||||
|
// Calculate visible area
|
||||||
|
const visible_lines = text_area.h / line_height;
|
||||||
|
const visible_cols = text_area.w / char_width;
|
||||||
|
|
||||||
|
// Handle keyboard input if focused
|
||||||
|
if (state.focused and !config.readonly) {
|
||||||
|
const text_in = ctx.input.getTextInput();
|
||||||
|
if (text_in.len > 0) {
|
||||||
|
// Check for tab
|
||||||
|
for (text_in) |c| {
|
||||||
|
if (c == '\t') {
|
||||||
|
// Insert spaces for tab
|
||||||
|
var spaces: [8]u8 = undefined;
|
||||||
|
const count = @min(config.tab_size, 8);
|
||||||
|
@memset(spaces[0..count], ' ');
|
||||||
|
state.insert(spaces[0..count]);
|
||||||
|
} else {
|
||||||
|
state.insert(&[_]u8{c});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cursor is visible
|
||||||
|
state.ensureCursorVisible(visible_lines, visible_cols);
|
||||||
|
|
||||||
|
// Get cursor position
|
||||||
|
const cursor_pos = state.getCursorPosition();
|
||||||
|
result.cursor_line = cursor_pos.line;
|
||||||
|
result.cursor_col = cursor_pos.col;
|
||||||
|
|
||||||
|
// Draw text line by line
|
||||||
|
const txt = state.text();
|
||||||
|
var line_num: usize = 0;
|
||||||
|
var line_start: usize = 0;
|
||||||
|
|
||||||
|
for (txt, 0..) |c, i| {
|
||||||
|
if (c == '\n') {
|
||||||
|
if (line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) {
|
||||||
|
const draw_line = line_num - state.scroll_y;
|
||||||
|
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
|
||||||
|
|
||||||
|
// Draw line number
|
||||||
|
if (config.line_numbers) {
|
||||||
|
drawLineNumber(
|
||||||
|
ctx,
|
||||||
|
bounds.x + @as(i32, @intCast(config.padding)),
|
||||||
|
y,
|
||||||
|
line_num + 1,
|
||||||
|
colors.line_numbers_fg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line text
|
||||||
|
const line_text = txt[line_start..i];
|
||||||
|
drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text);
|
||||||
|
|
||||||
|
// Draw selection on this line
|
||||||
|
if (state.selection_start != null) {
|
||||||
|
drawLineSelection(
|
||||||
|
ctx,
|
||||||
|
text_area.x,
|
||||||
|
y,
|
||||||
|
line_start,
|
||||||
|
i,
|
||||||
|
state.cursor,
|
||||||
|
state.selection_start.?,
|
||||||
|
state.scroll_x,
|
||||||
|
visible_cols,
|
||||||
|
char_width,
|
||||||
|
line_height,
|
||||||
|
colors.selection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cursor if on this line
|
||||||
|
if (state.focused and cursor_pos.line == line_num) {
|
||||||
|
const cursor_x_pos = cursor_pos.col -| state.scroll_x;
|
||||||
|
if (cursor_x_pos < visible_cols) {
|
||||||
|
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
|
||||||
|
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line_num += 1;
|
||||||
|
line_start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle last line (no trailing newline)
|
||||||
|
if (line_start <= txt.len and line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) {
|
||||||
|
const draw_line = line_num - state.scroll_y;
|
||||||
|
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
|
||||||
|
|
||||||
|
// Draw line number
|
||||||
|
if (config.line_numbers) {
|
||||||
|
drawLineNumber(
|
||||||
|
ctx,
|
||||||
|
bounds.x + @as(i32, @intCast(config.padding)),
|
||||||
|
y,
|
||||||
|
line_num + 1,
|
||||||
|
colors.line_numbers_fg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line text
|
||||||
|
const line_text = if (line_start < txt.len) txt[line_start..] else "";
|
||||||
|
drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text);
|
||||||
|
|
||||||
|
// Draw selection on this line
|
||||||
|
if (state.selection_start != null) {
|
||||||
|
drawLineSelection(
|
||||||
|
ctx,
|
||||||
|
text_area.x,
|
||||||
|
y,
|
||||||
|
line_start,
|
||||||
|
txt.len,
|
||||||
|
state.cursor,
|
||||||
|
state.selection_start.?,
|
||||||
|
state.scroll_x,
|
||||||
|
visible_cols,
|
||||||
|
char_width,
|
||||||
|
line_height,
|
||||||
|
colors.selection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cursor if on this line
|
||||||
|
if (state.focused and cursor_pos.line == line_num) {
|
||||||
|
const cursor_x_pos = cursor_pos.col -| state.scroll_x;
|
||||||
|
if (cursor_x_pos < visible_cols) {
|
||||||
|
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
|
||||||
|
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw placeholder if empty
|
||||||
|
if (state.len == 0 and config.placeholder.len > 0) {
|
||||||
|
const y = text_area.y;
|
||||||
|
ctx.pushCommand(Command.text(text_area.x, y, config.placeholder, colors.placeholder));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a line number
|
||||||
|
fn drawLineNumber(ctx: *Context, x: i32, y: i32, num: usize, color: Style.Color) void {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
const written = std.fmt.bufPrint(&buf, "{d}", .{num}) catch return;
|
||||||
|
ctx.pushCommand(Command.text(x, y, written, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw line text with horizontal scroll
|
||||||
|
fn drawLineText(
|
||||||
|
ctx: *Context,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
line: []const u8,
|
||||||
|
scroll_x: usize,
|
||||||
|
visible_cols: usize,
|
||||||
|
color: Style.Color,
|
||||||
|
) void {
|
||||||
|
if (line.len == 0) return;
|
||||||
|
|
||||||
|
const start = @min(scroll_x, line.len);
|
||||||
|
const end = @min(scroll_x + visible_cols, line.len);
|
||||||
|
|
||||||
|
if (start >= end) return;
|
||||||
|
|
||||||
|
ctx.pushCommand(Command.text(x, y, line[start..end], color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw selection highlight for a line
|
||||||
|
fn drawLineSelection(
|
||||||
|
ctx: *Context,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
line_start: usize,
|
||||||
|
line_end: usize,
|
||||||
|
cursor: usize,
|
||||||
|
sel_start: usize,
|
||||||
|
scroll_x: usize,
|
||||||
|
visible_cols: usize,
|
||||||
|
char_width: u32,
|
||||||
|
line_height: u32,
|
||||||
|
color: Style.Color,
|
||||||
|
) void {
|
||||||
|
const sel_min = @min(cursor, sel_start);
|
||||||
|
const sel_max = @max(cursor, sel_start);
|
||||||
|
|
||||||
|
// Check if selection overlaps this line
|
||||||
|
if (sel_max < line_start or sel_min > line_end) return;
|
||||||
|
|
||||||
|
// Calculate selection bounds within line
|
||||||
|
const sel_line_start = if (sel_min > line_start) sel_min - line_start else 0;
|
||||||
|
const sel_line_end = @min(sel_max, line_end) - line_start;
|
||||||
|
|
||||||
|
if (sel_line_start >= sel_line_end) return;
|
||||||
|
|
||||||
|
// Apply horizontal scroll
|
||||||
|
const vis_start = if (sel_line_start > scroll_x) sel_line_start - scroll_x else 0;
|
||||||
|
const vis_end = if (sel_line_end > scroll_x) @min(sel_line_end - scroll_x, visible_cols) else 0;
|
||||||
|
|
||||||
|
if (vis_start >= vis_end) return;
|
||||||
|
|
||||||
|
const sel_x = x + @as(i32, @intCast(vis_start * char_width));
|
||||||
|
const sel_w = @as(u32, @intCast(vis_end - vis_start)) * char_width;
|
||||||
|
|
||||||
|
ctx.pushCommand(Command.rect(sel_x, y, sel_w, line_height, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count digits in a number
|
||||||
|
fn countDigits(n: usize) usize {
|
||||||
|
if (n == 0) return 1;
|
||||||
|
var count: usize = 0;
|
||||||
|
var num = n;
|
||||||
|
while (num > 0) : (num /= 10) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "TextAreaState insert" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Hello");
|
||||||
|
try std.testing.expectEqualStrings("Hello", state.text());
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), state.cursor);
|
||||||
|
|
||||||
|
state.insertNewline();
|
||||||
|
state.insert("World");
|
||||||
|
try std.testing.expectEqualStrings("Hello\nWorld", state.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TextAreaState line count" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Line 1");
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), state.lineCount());
|
||||||
|
|
||||||
|
state.insertNewline();
|
||||||
|
state.insert("Line 2");
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), state.lineCount());
|
||||||
|
|
||||||
|
state.insertNewline();
|
||||||
|
state.insertNewline();
|
||||||
|
state.insert("Line 4");
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), state.lineCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TextAreaState cursor position" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Hello\nWorld\nTest");
|
||||||
|
|
||||||
|
// Cursor at end
|
||||||
|
const pos = state.getCursorPosition();
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), pos.line);
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), pos.col);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TextAreaState cursor up/down" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Line 1\nLine 2\nLine 3");
|
||||||
|
|
||||||
|
// Move up
|
||||||
|
state.cursorUp(false);
|
||||||
|
var pos = state.getCursorPosition();
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), pos.line);
|
||||||
|
|
||||||
|
state.cursorUp(false);
|
||||||
|
pos = state.getCursorPosition();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), pos.line);
|
||||||
|
|
||||||
|
// Move down
|
||||||
|
state.cursorDown(false);
|
||||||
|
pos = state.getCursorPosition();
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), pos.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TextAreaState home/end" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Hello World");
|
||||||
|
state.cursorHome(false);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), state.cursor);
|
||||||
|
|
||||||
|
state.cursorEnd(false);
|
||||||
|
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TextAreaState selection" {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
state.insert("Hello World");
|
||||||
|
state.selectAll();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(?usize, 0), state.selection_start);
|
||||||
|
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||||
|
|
||||||
|
state.insert("X");
|
||||||
|
try std.testing.expectEqualStrings("X", state.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "textArea generates commands" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var state = TextAreaState.init(&buf);
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 100;
|
||||||
|
|
||||||
|
_ = textArea(&ctx, &state);
|
||||||
|
|
||||||
|
// Should generate: rect (bg) + rect_outline (border)
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "countDigits" {
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), countDigits(0));
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), countDigits(5));
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), countDigits(10));
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), countDigits(100));
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), countDigits(1234));
|
||||||
|
}
|
||||||
467
src/widgets/tree.zig
Normal file
467
src/widgets/tree.zig
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
//! Tree Widget - Hierarchical tree view
|
||||||
|
//!
|
||||||
|
//! A collapsible tree structure for displaying hierarchical data.
|
||||||
|
//! Supports keyboard navigation, selection, and expansion/collapse.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// Tree node ID (for tracking state)
|
||||||
|
pub const NodeId = u64;
|
||||||
|
|
||||||
|
/// Tree node definition
|
||||||
|
pub const TreeNode = struct {
|
||||||
|
/// Unique ID for this node
|
||||||
|
id: NodeId,
|
||||||
|
/// Label text
|
||||||
|
label: []const u8,
|
||||||
|
/// Optional icon (single character)
|
||||||
|
icon: ?u8 = null,
|
||||||
|
/// Whether this node has children
|
||||||
|
has_children: bool = false,
|
||||||
|
/// Depth level (0 = root)
|
||||||
|
depth: u8 = 0,
|
||||||
|
/// User data pointer
|
||||||
|
user_data: ?*anyopaque = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tree state (caller-managed)
|
||||||
|
pub const TreeState = struct {
|
||||||
|
/// Allocator for dynamic data
|
||||||
|
allocator: Allocator,
|
||||||
|
/// Set of expanded node IDs
|
||||||
|
expanded: std.AutoHashMap(NodeId, void),
|
||||||
|
/// Currently selected node ID
|
||||||
|
selected: ?NodeId = null,
|
||||||
|
/// Scroll offset (rows)
|
||||||
|
scroll_y: usize = 0,
|
||||||
|
/// Whether tree has focus
|
||||||
|
focused: bool = false,
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
/// Initialize tree state
|
||||||
|
pub fn init(allocator: Allocator) Self {
|
||||||
|
return .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.expanded = std.AutoHashMap(NodeId, void).init(allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deinitialize and free resources
|
||||||
|
pub fn deinit(self: *Self) void {
|
||||||
|
self.expanded.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a node is expanded
|
||||||
|
pub fn isExpanded(self: Self, id: NodeId) bool {
|
||||||
|
return self.expanded.contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand a node
|
||||||
|
pub fn expand(self: *Self, id: NodeId) void {
|
||||||
|
self.expanded.put(id, {}) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse a node
|
||||||
|
pub fn collapse(self: *Self, id: NodeId) void {
|
||||||
|
_ = self.expanded.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle expand/collapse
|
||||||
|
pub fn toggle(self: *Self, id: NodeId) void {
|
||||||
|
if (self.isExpanded(id)) {
|
||||||
|
self.collapse(id);
|
||||||
|
} else {
|
||||||
|
self.expand(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand all nodes from a list
|
||||||
|
pub fn expandAll(self: *Self, nodes: []const TreeNode) void {
|
||||||
|
for (nodes) |node| {
|
||||||
|
if (node.has_children) {
|
||||||
|
self.expand(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse all nodes
|
||||||
|
pub fn collapseAll(self: *Self) void {
|
||||||
|
self.expanded.clearRetainingCapacity();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tree configuration
|
||||||
|
pub const TreeConfig = struct {
|
||||||
|
/// Indent width per level (pixels)
|
||||||
|
indent: u32 = 16,
|
||||||
|
/// Row height
|
||||||
|
row_height: u32 = 20,
|
||||||
|
/// Show icons
|
||||||
|
show_icons: bool = true,
|
||||||
|
/// Show expand/collapse indicators
|
||||||
|
show_indicators: bool = true,
|
||||||
|
/// Enable keyboard navigation
|
||||||
|
keyboard_nav: bool = true,
|
||||||
|
/// Allow multiple selection (future)
|
||||||
|
multi_select: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tree colors
|
||||||
|
pub const TreeColors = struct {
|
||||||
|
background: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
||||||
|
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||||
|
text_selected: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||||
|
icon: Style.Color = Style.Color.rgba(180, 180, 180, 255),
|
||||||
|
indicator: Style.Color = Style.Color.rgba(150, 150, 150, 255),
|
||||||
|
selected_bg: Style.Color = Style.Color.rgba(50, 100, 150, 255),
|
||||||
|
hovered_bg: Style.Color = Style.Color.rgba(60, 60, 60, 255),
|
||||||
|
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||||
|
guide_line: Style.Color = Style.Color.rgba(60, 60, 60, 255),
|
||||||
|
|
||||||
|
pub fn fromTheme(theme: Style.Theme) TreeColors {
|
||||||
|
return .{
|
||||||
|
.background = theme.background,
|
||||||
|
.text = theme.foreground,
|
||||||
|
.text_selected = theme.foreground,
|
||||||
|
.icon = theme.secondary,
|
||||||
|
.indicator = theme.secondary,
|
||||||
|
.selected_bg = theme.selection_bg,
|
||||||
|
.hovered_bg = theme.background.lighten(10),
|
||||||
|
.border = theme.border,
|
||||||
|
.guide_line = theme.border.darken(20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of tree widget
|
||||||
|
pub const TreeResult = struct {
|
||||||
|
/// A node was selected
|
||||||
|
selected: ?NodeId = null,
|
||||||
|
/// A node was double-clicked (or Enter pressed)
|
||||||
|
activated: ?NodeId = null,
|
||||||
|
/// A node was expanded
|
||||||
|
expanded: ?NodeId = null,
|
||||||
|
/// A node was collapsed
|
||||||
|
collapsed: ?NodeId = null,
|
||||||
|
/// Widget was clicked
|
||||||
|
clicked: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Callback type for getting child nodes
|
||||||
|
pub const GetChildrenFn = *const fn (parent_id: ?NodeId, user_data: ?*anyopaque) []const TreeNode;
|
||||||
|
|
||||||
|
/// Draw a tree and return interaction result
|
||||||
|
pub fn tree(
|
||||||
|
ctx: *Context,
|
||||||
|
state: *TreeState,
|
||||||
|
nodes: []const TreeNode,
|
||||||
|
) TreeResult {
|
||||||
|
return treeEx(ctx, state, nodes, .{}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a tree with custom configuration
|
||||||
|
pub fn treeEx(
|
||||||
|
ctx: *Context,
|
||||||
|
state: *TreeState,
|
||||||
|
nodes: []const TreeNode,
|
||||||
|
config: TreeConfig,
|
||||||
|
colors: TreeColors,
|
||||||
|
) TreeResult {
|
||||||
|
const bounds = ctx.layout.nextRect();
|
||||||
|
return treeRect(ctx, bounds, state, nodes, config, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a tree in a specific rectangle
|
||||||
|
pub fn treeRect(
|
||||||
|
ctx: *Context,
|
||||||
|
bounds: Layout.Rect,
|
||||||
|
state: *TreeState,
|
||||||
|
nodes: []const TreeNode,
|
||||||
|
config: TreeConfig,
|
||||||
|
colors: TreeColors,
|
||||||
|
) TreeResult {
|
||||||
|
var result = TreeResult{};
|
||||||
|
|
||||||
|
if (bounds.isEmpty()) return result;
|
||||||
|
|
||||||
|
// Check mouse interaction
|
||||||
|
const mouse = ctx.input.mousePos();
|
||||||
|
const hovered = bounds.contains(mouse.x, mouse.y);
|
||||||
|
const clicked = hovered and ctx.input.mousePressed(.left);
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
state.focused = true;
|
||||||
|
result.clicked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background));
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border));
|
||||||
|
|
||||||
|
// Calculate visible rows
|
||||||
|
const inner = bounds.shrink(1);
|
||||||
|
if (inner.isEmpty()) return result;
|
||||||
|
|
||||||
|
const visible_rows = inner.h / config.row_height;
|
||||||
|
const row_height = config.row_height;
|
||||||
|
|
||||||
|
// Filter visible nodes (expanded parents only)
|
||||||
|
var visible_count: usize = 0;
|
||||||
|
var row_index: usize = 0;
|
||||||
|
|
||||||
|
for (nodes) |node| {
|
||||||
|
// Check if all ancestors are expanded
|
||||||
|
if (!isNodeVisible(nodes, node, state)) continue;
|
||||||
|
|
||||||
|
// Check if in scroll range
|
||||||
|
if (row_index >= state.scroll_y and visible_count < visible_rows) {
|
||||||
|
const y = inner.y + @as(i32, @intCast(visible_count * row_height));
|
||||||
|
|
||||||
|
// Check if this row is hovered
|
||||||
|
const row_hovered = hovered and
|
||||||
|
mouse.y >= y and
|
||||||
|
mouse.y < y + @as(i32, @intCast(row_height));
|
||||||
|
|
||||||
|
// Handle click on this row
|
||||||
|
if (row_hovered and clicked) {
|
||||||
|
// Check if click is on expand indicator
|
||||||
|
const indicator_x = inner.x + @as(i32, @intCast(node.depth * config.indent));
|
||||||
|
const indicator_w: i32 = if (config.show_indicators) 12 else 0;
|
||||||
|
|
||||||
|
if (node.has_children and config.show_indicators and
|
||||||
|
mouse.x >= indicator_x and mouse.x < indicator_x + indicator_w)
|
||||||
|
{
|
||||||
|
// Toggle expand/collapse
|
||||||
|
state.toggle(node.id);
|
||||||
|
if (state.isExpanded(node.id)) {
|
||||||
|
result.expanded = node.id;
|
||||||
|
} else {
|
||||||
|
result.collapsed = node.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Select the node
|
||||||
|
state.selected = node.id;
|
||||||
|
result.selected = node.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw row
|
||||||
|
const is_selected = state.selected != null and state.selected.? == node.id;
|
||||||
|
drawTreeRow(ctx, inner, y, node, state, config, colors, is_selected, row_hovered);
|
||||||
|
|
||||||
|
visible_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a node is visible (all ancestors expanded)
|
||||||
|
fn isNodeVisible(nodes: []const TreeNode, node: TreeNode, state: *TreeState) bool {
|
||||||
|
if (node.depth == 0) return true;
|
||||||
|
|
||||||
|
// Find parent at depth-1 that contains this node
|
||||||
|
// This is a simplification - in a real tree you'd track parent IDs
|
||||||
|
var target_depth = node.depth - 1;
|
||||||
|
var i: usize = 0;
|
||||||
|
|
||||||
|
for (nodes, 0..) |n, idx| {
|
||||||
|
if (n.id == node.id) {
|
||||||
|
i = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk backwards to find ancestors
|
||||||
|
while (target_depth > 0) : (target_depth -= 1) {
|
||||||
|
var found = false;
|
||||||
|
var j = i;
|
||||||
|
while (j > 0) {
|
||||||
|
j -= 1;
|
||||||
|
if (nodes[j].depth == target_depth) {
|
||||||
|
if (!state.isExpanded(nodes[j].id)) return false;
|
||||||
|
found = true;
|
||||||
|
i = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found and target_depth > 0) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check immediate parent (depth-1)
|
||||||
|
if (node.depth > 0) {
|
||||||
|
var j: usize = 0;
|
||||||
|
for (nodes, 0..) |n, idx| {
|
||||||
|
if (n.id == node.id) {
|
||||||
|
j = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j > 0) {
|
||||||
|
j -= 1;
|
||||||
|
if (nodes[j].depth == node.depth - 1) {
|
||||||
|
return state.isExpanded(nodes[j].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a single tree row
|
||||||
|
fn drawTreeRow(
|
||||||
|
ctx: *Context,
|
||||||
|
area: Layout.Rect,
|
||||||
|
y: i32,
|
||||||
|
node: TreeNode,
|
||||||
|
state: *const TreeState,
|
||||||
|
config: TreeConfig,
|
||||||
|
colors: TreeColors,
|
||||||
|
is_selected: bool,
|
||||||
|
is_hovered: bool,
|
||||||
|
) void {
|
||||||
|
const row_height = config.row_height;
|
||||||
|
var x = area.x + @as(i32, @intCast(node.depth * config.indent));
|
||||||
|
|
||||||
|
// Draw selection/hover background
|
||||||
|
if (is_selected) {
|
||||||
|
ctx.pushCommand(Command.rect(area.x, y, area.w, row_height, colors.selected_bg));
|
||||||
|
} else if (is_hovered) {
|
||||||
|
ctx.pushCommand(Command.rect(area.x, y, area.w, row_height, colors.hovered_bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw expand/collapse indicator
|
||||||
|
if (config.show_indicators and node.has_children) {
|
||||||
|
const expanded = state.isExpanded(node.id);
|
||||||
|
const indicator = if (expanded) "-" else "+";
|
||||||
|
const text_y = y + @as(i32, @intCast((row_height -| 8) / 2));
|
||||||
|
ctx.pushCommand(Command.text(x, text_y, indicator, colors.indicator));
|
||||||
|
}
|
||||||
|
|
||||||
|
x += if (config.show_indicators) 12 else 0;
|
||||||
|
|
||||||
|
// Draw icon
|
||||||
|
if (config.show_icons) {
|
||||||
|
if (node.icon) |icon| {
|
||||||
|
const icon_str = &[_]u8{icon};
|
||||||
|
const text_y = y + @as(i32, @intCast((row_height -| 8) / 2));
|
||||||
|
ctx.pushCommand(Command.text(x, text_y, icon_str, colors.icon));
|
||||||
|
}
|
||||||
|
x += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
const text_color = if (is_selected) colors.text_selected else colors.text;
|
||||||
|
const text_y = y + @as(i32, @intCast((row_height -| 8) / 2));
|
||||||
|
ctx.pushCommand(Command.text(x, text_y, node.label, text_color));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
test "TreeState expand/collapse" {
|
||||||
|
var state = TreeState.init(std.testing.allocator);
|
||||||
|
defer state.deinit();
|
||||||
|
|
||||||
|
// Initially not expanded
|
||||||
|
try std.testing.expect(!state.isExpanded(1));
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
state.expand(1);
|
||||||
|
try std.testing.expect(state.isExpanded(1));
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
state.collapse(1);
|
||||||
|
try std.testing.expect(!state.isExpanded(1));
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
state.toggle(2);
|
||||||
|
try std.testing.expect(state.isExpanded(2));
|
||||||
|
state.toggle(2);
|
||||||
|
try std.testing.expect(!state.isExpanded(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TreeState selection" {
|
||||||
|
var state = TreeState.init(std.testing.allocator);
|
||||||
|
defer state.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(?NodeId, null), state.selected);
|
||||||
|
|
||||||
|
state.selected = 42;
|
||||||
|
try std.testing.expectEqual(@as(?NodeId, 42), state.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "TreeState expandAll/collapseAll" {
|
||||||
|
var state = TreeState.init(std.testing.allocator);
|
||||||
|
defer state.deinit();
|
||||||
|
|
||||||
|
const nodes = [_]TreeNode{
|
||||||
|
.{ .id = 1, .label = "Root", .has_children = true },
|
||||||
|
.{ .id = 2, .label = "Child 1", .has_children = true, .depth = 1 },
|
||||||
|
.{ .id = 3, .label = "Child 2", .has_children = false, .depth = 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
state.expandAll(&nodes);
|
||||||
|
try std.testing.expect(state.isExpanded(1));
|
||||||
|
try std.testing.expect(state.isExpanded(2));
|
||||||
|
try std.testing.expect(!state.isExpanded(3)); // no children
|
||||||
|
|
||||||
|
state.collapseAll();
|
||||||
|
try std.testing.expect(!state.isExpanded(1));
|
||||||
|
try std.testing.expect(!state.isExpanded(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "tree generates commands" {
|
||||||
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
var state = TreeState.init(std.testing.allocator);
|
||||||
|
defer state.deinit();
|
||||||
|
|
||||||
|
const nodes = [_]TreeNode{
|
||||||
|
.{ .id = 1, .label = "Root" },
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.beginFrame();
|
||||||
|
ctx.layout.row_height = 200;
|
||||||
|
|
||||||
|
_ = tree(&ctx, &state, &nodes);
|
||||||
|
|
||||||
|
// Should generate: rect (bg) + rect_outline (border) + text (label)
|
||||||
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||||
|
|
||||||
|
ctx.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isNodeVisible" {
|
||||||
|
var state = TreeState.init(std.testing.allocator);
|
||||||
|
defer state.deinit();
|
||||||
|
|
||||||
|
const nodes = [_]TreeNode{
|
||||||
|
.{ .id = 1, .label = "Root", .has_children = true, .depth = 0 },
|
||||||
|
.{ .id = 2, .label = "Child", .depth = 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Root is always visible
|
||||||
|
try std.testing.expect(isNodeVisible(&nodes, nodes[0], &state));
|
||||||
|
|
||||||
|
// Child not visible when parent not expanded
|
||||||
|
try std.testing.expect(!isNodeVisible(&nodes, nodes[1], &state));
|
||||||
|
|
||||||
|
// Child visible when parent expanded
|
||||||
|
state.expand(1);
|
||||||
|
try std.testing.expect(isNodeVisible(&nodes, nodes[1], &state));
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,9 @@ pub const radio = @import("radio.zig");
|
||||||
pub const progress = @import("progress.zig");
|
pub const progress = @import("progress.zig");
|
||||||
pub const tooltip = @import("tooltip.zig");
|
pub const tooltip = @import("tooltip.zig");
|
||||||
pub const toast = @import("toast.zig");
|
pub const toast = @import("toast.zig");
|
||||||
|
pub const textarea = @import("textarea.zig");
|
||||||
|
pub const tree = @import("tree.zig");
|
||||||
|
pub const badge = @import("badge.zig");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Re-exports for convenience
|
// Re-exports for convenience
|
||||||
|
|
@ -193,6 +196,33 @@ pub const ToastColors = toast.Colors;
|
||||||
pub const ToastPosition = toast.Position;
|
pub const ToastPosition = toast.Position;
|
||||||
pub const ToastResult = toast.ToastResult;
|
pub const ToastResult = toast.ToastResult;
|
||||||
|
|
||||||
|
// TextArea
|
||||||
|
pub const TextArea = textarea;
|
||||||
|
pub const TextAreaState = textarea.TextAreaState;
|
||||||
|
pub const TextAreaConfig = textarea.TextAreaConfig;
|
||||||
|
pub const TextAreaColors = textarea.TextAreaColors;
|
||||||
|
pub const TextAreaResult = textarea.TextAreaResult;
|
||||||
|
|
||||||
|
// Tree
|
||||||
|
pub const Tree = tree;
|
||||||
|
pub const TreeNode = tree.TreeNode;
|
||||||
|
pub const TreeState = tree.TreeState;
|
||||||
|
pub const TreeConfig = tree.TreeConfig;
|
||||||
|
pub const TreeColors = tree.TreeColors;
|
||||||
|
pub const TreeResult = tree.TreeResult;
|
||||||
|
pub const NodeId = tree.NodeId;
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
pub const Badge = badge;
|
||||||
|
pub const BadgeVariant = badge.Variant;
|
||||||
|
pub const BadgeSize = badge.Size;
|
||||||
|
pub const BadgeConfig = badge.Config;
|
||||||
|
pub const BadgeColors = badge.Colors;
|
||||||
|
pub const BadgeResult = badge.Result;
|
||||||
|
pub const Tag = badge.Tag;
|
||||||
|
pub const TagGroupConfig = badge.TagGroupConfig;
|
||||||
|
pub const TagGroupResult = badge.TagGroupResult;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue