refactor: Split textarea.zig and progress.zig into modular structures

Split large widget files for better maintainability (~500-600 lines per file):

textarea/ (was 882 lines):
- types.zig: TextAreaConfig, TextAreaColors, TextAreaResult
- state.zig: TextAreaState with cursor/selection methods
- render.zig: drawLineNumber, drawLineText, drawLineSelection
- textarea.zig: Main API with re-exports and tests

progress/ (was 806 lines):
- render.zig: Shared drawing helpers (stripes, gradients, arcs)
- bar.zig: ProgressBar widget
- circle.zig: ProgressCircle widget
- spinner.zig: Spinner widget with animation state
- progress.zig: Main API with re-exports and tests

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-11 23:21:06 +01:00
parent cfe4ee7935
commit 59935aeb2b
12 changed files with 1813 additions and 1690 deletions

View file

@ -1,806 +0,0 @@
//! Progress Widget
//!
//! Visual feedback widgets for progress and loading states.
//!
//! ## Widgets
//! - **ProgressBar**: Horizontal/vertical progress bar
//! - **ProgressCircle**: Circular progress indicator
//! - **Spinner**: Animated loading indicator
//!
//! ## Usage
//! ```zig
//! // Simple progress bar
//! progress.bar(ctx, 0.75);
//!
//! // Progress with config
//! progress.barEx(ctx, 0.5, .{
//! .show_percentage = true,
//! .style = .striped,
//! });
//!
//! // Indeterminate spinner
//! progress.spinner(ctx, .{ .style = .circular });
//! ```
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 Rect = Layout.Rect;
const Color = Style.Color;
// =============================================================================
// Progress Bar
// =============================================================================
/// Progress bar style
pub const BarStyle = enum {
/// Solid fill
solid,
/// Striped pattern (animated)
striped,
/// Gradient fill
gradient,
/// Segmented blocks
segmented,
};
/// Progress bar configuration
pub const BarConfig = struct {
/// Visual style
style: BarStyle = .solid,
/// Show percentage text
show_percentage: bool = true,
/// Custom label (overrides percentage)
label: ?[]const u8 = null,
/// Orientation
vertical: bool = false,
/// Height (for horizontal) or Width (for vertical)
thickness: u16 = 20,
/// Corner radius
corner_radius: u8 = 4,
/// Animation enabled (for striped style)
animated: bool = true,
/// Track color (background)
track_color: ?Color = null,
/// Fill color
fill_color: ?Color = null,
/// Text color
text_color: ?Color = null,
/// Number of segments (for segmented style)
segments: u8 = 10,
};
/// Progress bar result
pub const BarResult = struct {
/// The bounds used
bounds: Rect,
/// Current progress value (clamped 0-1)
progress: f32,
};
/// Simple progress bar with default styling
pub fn bar(ctx: *Context, value: f32) BarResult {
return barEx(ctx, value, .{});
}
/// Progress bar with configuration
pub fn barEx(ctx: *Context, value: f32, config: BarConfig) BarResult {
// Get theme colors
const theme = Style.currentTheme();
const track_color = config.track_color orelse theme.border;
const fill_color = config.fill_color orelse theme.primary;
const text_color = config.text_color orelse theme.text_primary;
// Clamp progress value
const progress = std.math.clamp(value, 0.0, 1.0);
// Calculate bounds based on layout
const layout_rect = ctx.layout.area;
const bounds = if (config.vertical)
Rect.init(layout_rect.x, layout_rect.y, config.thickness, layout_rect.h)
else
Rect.init(layout_rect.x, layout_rect.y, layout_rect.w, config.thickness);
// Draw track (background)
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y,
.w = bounds.w,
.h = bounds.h,
.color = track_color,
},
});
// Calculate fill dimensions
const fill_bounds = if (config.vertical) blk: {
const fill_height: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.h)) * progress);
const fill_y = bounds.y + @as(i32, @intCast(bounds.h - fill_height));
break :blk Rect.init(bounds.x, fill_y, bounds.w, fill_height);
} else blk: {
const fill_width: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.w)) * progress);
break :blk Rect.init(bounds.x, bounds.y, fill_width, bounds.h);
};
// Draw fill based on style
switch (config.style) {
.solid => {
if (fill_bounds.w > 0 and fill_bounds.h > 0) {
ctx.pushCommand(.{
.rect = .{
.x = fill_bounds.x,
.y = fill_bounds.y,
.w = fill_bounds.w,
.h = fill_bounds.h,
.color = fill_color,
},
});
}
},
.striped => {
drawStripedFill(ctx, fill_bounds, fill_color, config.animated);
},
.gradient => {
drawGradientFill(ctx, fill_bounds, fill_color, config.vertical);
},
.segmented => {
drawSegmentedFill(ctx, bounds, fill_color, progress, config.segments, config.vertical);
},
}
// Draw label or percentage
if (config.show_percentage or config.label != null) {
var label_buf: [32]u8 = undefined;
const label_text = if (config.label) |lbl|
lbl
else blk: {
const percent: u8 = @intFromFloat(progress * 100);
const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch "";
break :blk written;
};
// Center text in bar
const text_width: u32 = @intCast(label_text.len * 8); // Assuming 8px font
const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2));
const text_y = bounds.y + @as(i32, @intCast((bounds.h -| 8) / 2));
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label_text,
.color = text_color,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
.progress = progress,
};
}
/// Draw at specific bounds
pub fn barRect(ctx: *Context, bounds: Rect, value: f32, config: BarConfig) BarResult {
// Override layout temporarily
const saved_layout = ctx.layout;
ctx.layout = Layout.LayoutState.init(bounds.w, bounds.h);
ctx.layout.container = bounds;
const result = barEx(ctx, value, config);
ctx.layout = saved_layout;
return result;
}
// =============================================================================
// Progress Circle
// =============================================================================
/// Circle progress configuration
pub const CircleConfig = struct {
/// Diameter in pixels
diameter: u16 = 48,
/// Stroke width
stroke_width: u8 = 4,
/// Show percentage in center
show_percentage: bool = true,
/// Custom label
label: ?[]const u8 = null,
/// Start angle (0 = top, clockwise)
start_angle: f32 = 0,
/// Track color
track_color: ?Color = null,
/// Fill color
fill_color: ?Color = null,
/// Text color
text_color: ?Color = null,
};
/// Circle progress result
pub const CircleResult = struct {
bounds: Rect,
progress: f32,
};
/// Simple circle progress
pub fn circle(ctx: *Context, value: f32) CircleResult {
return circleEx(ctx, value, .{});
}
/// Circle progress with configuration
pub fn circleEx(ctx: *Context, value: f32, config: CircleConfig) CircleResult {
const theme = Style.currentTheme();
const track_color = config.track_color orelse theme.border;
const fill_color = config.fill_color orelse theme.primary;
const text_color = config.text_color orelse theme.text_primary;
const progress = std.math.clamp(value, 0.0, 1.0);
// Get bounds from layout
const layout_rect = ctx.layout.area;
const bounds = Rect.init(
layout_rect.x,
layout_rect.y,
config.diameter,
config.diameter,
);
const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
const radius: i32 = @intCast(config.diameter / 2 - config.stroke_width);
// Draw track circle (as approximation with arcs or just outline)
drawCircleOutline(ctx, center_x, center_y, radius, config.stroke_width, track_color);
// Draw progress arc
if (progress > 0) {
drawProgressArc(ctx, center_x, center_y, radius, config.stroke_width, fill_color, progress, config.start_angle);
}
// Draw label
if (config.show_percentage or config.label != null) {
var label_buf: [32]u8 = undefined;
const label_text = if (config.label) |lbl|
lbl
else blk: {
const percent: u8 = @intFromFloat(progress * 100);
const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch "";
break :blk written;
};
const text_width: u32 = @intCast(label_text.len * 8);
const text_x = center_x - @as(i32, @intCast(text_width / 2));
const text_y = center_y - 4;
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label_text,
.color = text_color,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
.progress = progress,
};
}
// =============================================================================
// Spinner
// =============================================================================
/// Spinner style
pub const SpinnerStyle = enum {
/// Rotating arc
circular,
/// Pulsing dots
dots,
/// Bouncing bars (equalizer)
bars,
/// Ring with gap
ring,
};
/// Spinner configuration
pub const SpinnerConfig = struct {
/// Visual style
style: SpinnerStyle = .circular,
/// Size in pixels
size: u16 = 24,
/// Animation speed multiplier
speed: f32 = 1.0,
/// Optional label below spinner
label: ?[]const u8 = null,
/// Primary color
color: ?Color = null,
/// Number of elements (dots or bars)
elements: u8 = 8,
};
/// Spinner state (for animation)
pub const SpinnerState = struct {
/// Animation progress (0-1, loops)
animation: f32 = 0,
/// Last update timestamp
last_update: i64 = 0,
pub fn update(self: *SpinnerState, speed: f32) void {
const now = std.time.milliTimestamp();
if (self.last_update == 0) {
self.last_update = now;
return;
}
const delta_ms = now - self.last_update;
self.last_update = now;
// Advance animation
const delta_f: f32 = @floatFromInt(delta_ms);
self.animation += (delta_f / 1000.0) * speed;
if (self.animation >= 1.0) {
self.animation -= 1.0;
}
}
};
/// Spinner result
pub const SpinnerResult = struct {
bounds: Rect,
};
/// Simple spinner
pub fn spinner(ctx: *Context, state: *SpinnerState) SpinnerResult {
return spinnerEx(ctx, state, .{});
}
/// Spinner with configuration
pub fn spinnerEx(ctx: *Context, state: *SpinnerState, config: SpinnerConfig) SpinnerResult {
const theme = Style.currentTheme();
const color = config.color orelse theme.primary;
// Update animation
state.update(config.speed);
// Get bounds
const layout_rect = ctx.layout.area;
const bounds = Rect.init(
layout_rect.x,
layout_rect.y,
config.size,
config.size,
);
const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
switch (config.style) {
.circular => {
drawRotatingArc(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation);
},
.dots => {
drawPulsingDots(ctx, center_x, center_y, config.size, color, config.elements, state.animation);
},
.bars => {
drawBouncingBars(ctx, bounds, color, config.elements, state.animation);
},
.ring => {
drawRingWithGap(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation);
},
}
// Draw label if present
if (config.label) |label| {
const text_x = bounds.x;
const text_y = bounds.y + @as(i32, @intCast(bounds.h)) + 4;
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label,
.color = theme.text_secondary,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
};
}
// =============================================================================
// Helper Drawing Functions
// =============================================================================
fn drawStripedFill(ctx: *Context, bounds: Rect, fill_color: Color, animated: bool) void {
_ = animated; // TODO: Use frame time for animation offset
if (bounds.w == 0 or bounds.h == 0) return;
// Draw base fill
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y,
.w = bounds.w,
.h = bounds.h,
.color = fill_color,
},
});
// Draw stripes (darker lines)
const stripe_color = Color.rgba(
fill_color.r -| 30,
fill_color.g -| 30,
fill_color.b -| 30,
fill_color.a,
);
const stripe_width: i32 = 6;
const stripe_gap: i32 = 12;
var x = bounds.x;
while (x < bounds.x + @as(i32, @intCast(bounds.w))) {
const stripe_h = @min(@as(u32, @intCast(@max(0, bounds.x + @as(i32, @intCast(bounds.w)) - x))), @as(u32, @intCast(stripe_width)));
if (stripe_h > 0) {
ctx.pushCommand(.{
.rect = .{
.x = x,
.y = bounds.y,
.w = stripe_h,
.h = bounds.h,
.color = stripe_color,
},
});
}
x += stripe_gap;
}
}
fn drawGradientFill(ctx: *Context, bounds: Rect, base_color: Color, vertical: bool) void {
if (bounds.w == 0 or bounds.h == 0) return;
// Simple gradient approximation with 4 bands
const bands: u32 = 4;
const steps = if (vertical) bounds.h / bands else bounds.w / bands;
var i: u32 = 0;
while (i < bands) : (i += 1) {
const t: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bands));
const brightness: u8 = @intFromFloat(t * 40);
const band_color = Color.rgba(
base_color.r -| brightness,
base_color.g -| brightness,
base_color.b -| brightness,
base_color.a,
);
if (vertical) {
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y + @as(i32, @intCast(i * steps)),
.w = bounds.w,
.h = steps,
.color = band_color,
},
});
} else {
ctx.pushCommand(.{
.rect = .{
.x = bounds.x + @as(i32, @intCast(i * steps)),
.y = bounds.y,
.w = steps,
.h = bounds.h,
.color = band_color,
},
});
}
}
}
fn drawSegmentedFill(ctx: *Context, bounds: Rect, fill_color: Color, progress: f32, segments: u8, vertical: bool) void {
const seg_count: u32 = segments;
const gap: u32 = 2;
if (vertical) {
const seg_height = (bounds.h - (seg_count - 1) * gap) / seg_count;
const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress);
var i: u32 = 0;
while (i < seg_count) : (i += 1) {
const seg_y = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast((i + 1) * (seg_height + gap)));
const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a);
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = seg_y,
.w = bounds.w,
.h = seg_height,
.color = color,
},
});
}
} else {
const seg_width = (bounds.w - (seg_count - 1) * gap) / seg_count;
const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress);
var i: u32 = 0;
while (i < seg_count) : (i += 1) {
const seg_x = bounds.x + @as(i32, @intCast(i * (seg_width + gap)));
const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a);
ctx.pushCommand(.{
.rect = .{
.x = seg_x,
.y = bounds.y,
.w = seg_width,
.h = bounds.h,
.color = color,
},
});
}
}
}
fn drawCircleOutline(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color) void {
// Approximate circle with octagon for simplicity in software rendering
const r = radius;
const s: i32 = @intCast(stroke);
// Draw 8 segments around the circle
const offsets = [_][2]i32{
.{ 0, -r }, // top
.{ r, 0 }, // right
.{ 0, r }, // bottom
.{ -r, 0 }, // left
};
for (offsets) |off| {
ctx.pushCommand(.{
.rect = .{
.x = cx + off[0] - @divTrunc(s, 2),
.y = cy + off[1] - @divTrunc(s, 2),
.w = @intCast(s),
.h = @intCast(s),
.color = color,
},
});
}
}
fn drawProgressArc(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color, progress: f32, start_angle: f32) void {
_ = start_angle;
// Simplified arc drawing - draw filled segments
const segments: u32 = 16;
const filled: u32 = @intFromFloat(@as(f32, @floatFromInt(segments)) * progress);
var i: u32 = 0;
while (i < filled) : (i += 1) {
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
ctx.pushCommand(.{
.rect = .{
.x = px - @divTrunc(@as(i32, stroke), 2),
.y = py - @divTrunc(@as(i32, stroke), 2),
.w = stroke,
.h = stroke,
.color = color,
},
});
}
}
fn drawRotatingArc(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void {
const segments: u32 = 8;
const arc_length: u32 = 5; // Number of segments in the arc
const start_seg: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments)));
var i: u32 = 0;
while (i < arc_length) : (i += 1) {
const seg = (start_seg + i) % segments;
const angle: f32 = (@as(f32, @floatFromInt(seg)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
// Fade based on position in arc
const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(arc_length - i)) / @as(f32, @floatFromInt(arc_length))) * 255);
const faded = Color.rgba(color.r, color.g, color.b, alpha);
ctx.pushCommand(.{
.rect = .{
.x = px - 2,
.y = py - 2,
.w = 4,
.h = 4,
.color = faded,
},
});
}
}
fn drawPulsingDots(ctx: *Context, cx: i32, cy: i32, size: u16, color: Color, count: u8, animation: f32) void {
const radius: f32 = @as(f32, @floatFromInt(size)) / 3.0;
var i: u8 = 0;
while (i < count) : (i += 1) {
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * radius));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * radius));
// Pulse based on animation and position
const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const scale: f32 = 0.5 + 0.5 * @sin(phase * 2.0 * std.math.pi);
const dot_size: u32 = @intFromFloat(2.0 + scale * 3.0);
ctx.pushCommand(.{
.rect = .{
.x = px - @as(i32, @intCast(dot_size / 2)),
.y = py - @as(i32, @intCast(dot_size / 2)),
.w = dot_size,
.h = dot_size,
.color = color,
},
});
}
}
fn drawBouncingBars(ctx: *Context, bounds: Rect, color: Color, count: u8, animation: f32) void {
const bar_width = bounds.w / @as(u32, count);
const max_height = bounds.h;
var i: u8 = 0;
while (i < count) : (i += 1) {
// Each bar bounces with phase offset
const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const bounce: f32 = @abs(@sin(phase * 2.0 * std.math.pi));
const bar_height: u32 = @intFromFloat(@as(f32, @floatFromInt(max_height)) * (0.3 + 0.7 * bounce));
const bar_x = bounds.x + @as(i32, @intCast(@as(u32, i) * bar_width));
const bar_y = bounds.y + @as(i32, @intCast(max_height - bar_height));
ctx.pushCommand(.{
.rect = .{
.x = bar_x + 1,
.y = bar_y,
.w = bar_width -| 2,
.h = bar_height,
.color = color,
},
});
}
}
fn drawRingWithGap(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void {
const segments: u32 = 12;
const gap_size: u32 = 3; // Number of segments for the gap
const gap_start: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments)));
var i: u32 = 0;
while (i < segments) : (i += 1) {
// Skip gap segments
const distance_from_gap = @min((i + segments - gap_start) % segments, (gap_start + segments - i) % segments);
if (distance_from_gap < gap_size / 2) continue;
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
ctx.pushCommand(.{
.rect = .{
.x = px - 2,
.y = py - 2,
.w = 4,
.h = 4,
.color = color,
},
});
}
}
// =============================================================================
// Tests
// =============================================================================
test "ProgressBar basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = bar(&ctx, 0.5);
try std.testing.expectEqual(@as(f32, 0.5), result.progress);
try std.testing.expect(ctx.commands.items.len > 0);
ctx.endFrame();
}
test "ProgressBar clamping" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
// Test value clamping
const result1 = bar(&ctx, -0.5);
try std.testing.expectEqual(@as(f32, 0.0), result1.progress);
const result2 = bar(&ctx, 1.5);
try std.testing.expectEqual(@as(f32, 1.0), result2.progress);
ctx.endFrame();
}
test "ProgressBar styles" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
// Test different styles
_ = barEx(&ctx, 0.75, .{ .style = .solid });
_ = barEx(&ctx, 0.75, .{ .style = .striped });
_ = barEx(&ctx, 0.75, .{ .style = .gradient });
_ = barEx(&ctx, 0.75, .{ .style = .segmented, .segments = 5 });
try std.testing.expect(ctx.commands.items.len > 0);
ctx.endFrame();
}
test "ProgressCircle basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = circle(&ctx, 0.75);
try std.testing.expectEqual(@as(f32, 0.75), result.progress);
ctx.endFrame();
}
test "Spinner state" {
var state = SpinnerState{};
// Initial state
try std.testing.expectEqual(@as(f32, 0), state.animation);
// Update advances animation
state.update(1.0);
state.last_update -= 100; // Simulate 100ms passed
state.update(1.0);
try std.testing.expect(state.animation >= 0);
}
test "Spinner basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
var state = SpinnerState{};
const result = spinner(&ctx, &state);
try std.testing.expect(result.bounds.w > 0);
ctx.endFrame();
}

View file

@ -0,0 +1,183 @@
//! ProgressBar Widget - Horizontal/vertical progress bar
//!
//! Part of the progress widget module.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
const Rect = Layout.Rect;
const Color = Style.Color;
const render = @import("render.zig");
// =============================================================================
// Types
// =============================================================================
/// Progress bar style
pub const BarStyle = enum {
/// Solid fill
solid,
/// Striped pattern (animated)
striped,
/// Gradient fill
gradient,
/// Segmented blocks
segmented,
};
/// Progress bar configuration
pub const BarConfig = struct {
/// Visual style
style: BarStyle = .solid,
/// Show percentage text
show_percentage: bool = true,
/// Custom label (overrides percentage)
label: ?[]const u8 = null,
/// Orientation
vertical: bool = false,
/// Height (for horizontal) or Width (for vertical)
thickness: u16 = 20,
/// Corner radius
corner_radius: u8 = 4,
/// Animation enabled (for striped style)
animated: bool = true,
/// Track color (background)
track_color: ?Color = null,
/// Fill color
fill_color: ?Color = null,
/// Text color
text_color: ?Color = null,
/// Number of segments (for segmented style)
segments: u8 = 10,
};
/// Progress bar result
pub const BarResult = struct {
/// The bounds used
bounds: Rect,
/// Current progress value (clamped 0-1)
progress: f32,
};
// =============================================================================
// Public API
// =============================================================================
/// Simple progress bar with default styling
pub fn bar(ctx: *Context, value: f32) BarResult {
return barEx(ctx, value, .{});
}
/// Progress bar with configuration
pub fn barEx(ctx: *Context, value: f32, config: BarConfig) BarResult {
// Get theme colors
const theme = Style.currentTheme();
const track_color = config.track_color orelse theme.border;
const fill_color = config.fill_color orelse theme.primary;
const text_color = config.text_color orelse theme.text_primary;
// Clamp progress value
const progress = std.math.clamp(value, 0.0, 1.0);
// Calculate bounds based on layout
const layout_rect = ctx.layout.area;
const bounds = if (config.vertical)
Rect.init(layout_rect.x, layout_rect.y, config.thickness, layout_rect.h)
else
Rect.init(layout_rect.x, layout_rect.y, layout_rect.w, config.thickness);
// Draw track (background)
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y,
.w = bounds.w,
.h = bounds.h,
.color = track_color,
},
});
// Calculate fill dimensions
const fill_bounds = if (config.vertical) blk: {
const fill_height: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.h)) * progress);
const fill_y = bounds.y + @as(i32, @intCast(bounds.h - fill_height));
break :blk Rect.init(bounds.x, fill_y, bounds.w, fill_height);
} else blk: {
const fill_width: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.w)) * progress);
break :blk Rect.init(bounds.x, bounds.y, fill_width, bounds.h);
};
// Draw fill based on style
switch (config.style) {
.solid => {
if (fill_bounds.w > 0 and fill_bounds.h > 0) {
ctx.pushCommand(.{
.rect = .{
.x = fill_bounds.x,
.y = fill_bounds.y,
.w = fill_bounds.w,
.h = fill_bounds.h,
.color = fill_color,
},
});
}
},
.striped => {
render.drawStripedFill(ctx, fill_bounds, fill_color, config.animated);
},
.gradient => {
render.drawGradientFill(ctx, fill_bounds, fill_color, config.vertical);
},
.segmented => {
render.drawSegmentedFill(ctx, bounds, fill_color, progress, config.segments, config.vertical);
},
}
// Draw label or percentage
if (config.show_percentage or config.label != null) {
var label_buf: [32]u8 = undefined;
const label_text = if (config.label) |lbl|
lbl
else blk: {
const percent: u8 = @intFromFloat(progress * 100);
const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch "";
break :blk written;
};
// Center text in bar
const text_width: u32 = @intCast(label_text.len * 8); // Assuming 8px font
const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2));
const text_y = bounds.y + @as(i32, @intCast((bounds.h -| 8) / 2));
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label_text,
.color = text_color,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
.progress = progress,
};
}
/// Draw at specific bounds
pub fn barRect(ctx: *Context, bounds: Rect, value: f32, config: BarConfig) BarResult {
// Override layout temporarily
const saved_layout = ctx.layout;
ctx.layout = Layout.LayoutState.init(bounds.w, bounds.h);
ctx.layout.container = bounds;
const result = barEx(ctx, value, config);
ctx.layout = saved_layout;
return result;
}

View file

@ -0,0 +1,114 @@
//! ProgressCircle Widget - Circular progress indicator
//!
//! Part of the progress widget module.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
const Rect = Layout.Rect;
const Color = Style.Color;
const render = @import("render.zig");
// =============================================================================
// Types
// =============================================================================
/// Circle progress configuration
pub const CircleConfig = struct {
/// Diameter in pixels
diameter: u16 = 48,
/// Stroke width
stroke_width: u8 = 4,
/// Show percentage in center
show_percentage: bool = true,
/// Custom label
label: ?[]const u8 = null,
/// Start angle (0 = top, clockwise)
start_angle: f32 = 0,
/// Track color
track_color: ?Color = null,
/// Fill color
fill_color: ?Color = null,
/// Text color
text_color: ?Color = null,
};
/// Circle progress result
pub const CircleResult = struct {
bounds: Rect,
progress: f32,
};
// =============================================================================
// Public API
// =============================================================================
/// Simple circle progress
pub fn circle(ctx: *Context, value: f32) CircleResult {
return circleEx(ctx, value, .{});
}
/// Circle progress with configuration
pub fn circleEx(ctx: *Context, value: f32, config: CircleConfig) CircleResult {
const theme = Style.currentTheme();
const track_color = config.track_color orelse theme.border;
const fill_color = config.fill_color orelse theme.primary;
const text_color = config.text_color orelse theme.text_primary;
const progress = std.math.clamp(value, 0.0, 1.0);
// Get bounds from layout
const layout_rect = ctx.layout.area;
const bounds = Rect.init(
layout_rect.x,
layout_rect.y,
config.diameter,
config.diameter,
);
const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
const radius: i32 = @intCast(config.diameter / 2 - config.stroke_width);
// Draw track circle (as approximation with arcs or just outline)
render.drawCircleOutline(ctx, center_x, center_y, radius, config.stroke_width, track_color);
// Draw progress arc
if (progress > 0) {
render.drawProgressArc(ctx, center_x, center_y, radius, config.stroke_width, fill_color, progress, config.start_angle);
}
// Draw label
if (config.show_percentage or config.label != null) {
var label_buf: [32]u8 = undefined;
const label_text = if (config.label) |lbl|
lbl
else blk: {
const percent: u8 = @intFromFloat(progress * 100);
const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch "";
break :blk written;
};
const text_width: u32 = @intCast(label_text.len * 8);
const text_x = center_x - @as(i32, @intCast(text_width / 2));
const text_y = center_y - 4;
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label_text,
.color = text_color,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
.progress = progress,
};
}

View file

@ -0,0 +1,151 @@
//! Progress Widget
//!
//! Visual feedback widgets for progress and loading states.
//!
//! ## Widgets
//! - **ProgressBar**: Horizontal/vertical progress bar
//! - **ProgressCircle**: Circular progress indicator
//! - **Spinner**: Animated loading indicator
//!
//! ## Usage
//! ```zig
//! // Simple progress bar
//! progress.bar(ctx, 0.75);
//!
//! // Progress with config
//! progress.barEx(ctx, 0.5, .{
//! .show_percentage = true,
//! .style = .striped,
//! });
//!
//! // Indeterminate spinner
//! progress.spinner(ctx, .{ .style = .circular });
//! ```
//!
//! This module re-exports types from the progress/ subdirectory.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
// =============================================================================
// Re-exports: Bar
// =============================================================================
pub const bar_module = @import("bar.zig");
pub const BarStyle = bar_module.BarStyle;
pub const BarConfig = bar_module.BarConfig;
pub const BarResult = bar_module.BarResult;
pub const bar = bar_module.bar;
pub const barEx = bar_module.barEx;
pub const barRect = bar_module.barRect;
// =============================================================================
// Re-exports: Circle
// =============================================================================
pub const circle_module = @import("circle.zig");
pub const CircleConfig = circle_module.CircleConfig;
pub const CircleResult = circle_module.CircleResult;
pub const circle = circle_module.circle;
pub const circleEx = circle_module.circleEx;
// =============================================================================
// Re-exports: Spinner
// =============================================================================
pub const spinner_module = @import("spinner.zig");
pub const SpinnerStyle = spinner_module.SpinnerStyle;
pub const SpinnerConfig = spinner_module.SpinnerConfig;
pub const SpinnerState = spinner_module.SpinnerState;
pub const SpinnerResult = spinner_module.SpinnerResult;
pub const spinner = spinner_module.spinner;
pub const spinnerEx = spinner_module.spinnerEx;
// =============================================================================
// Tests
// =============================================================================
test "ProgressBar basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = bar(&ctx, 0.5);
try std.testing.expectEqual(@as(f32, 0.5), result.progress);
try std.testing.expect(ctx.commands.items.len > 0);
ctx.endFrame();
}
test "ProgressBar clamping" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
// Test value clamping
const result1 = bar(&ctx, -0.5);
try std.testing.expectEqual(@as(f32, 0.0), result1.progress);
const result2 = bar(&ctx, 1.5);
try std.testing.expectEqual(@as(f32, 1.0), result2.progress);
ctx.endFrame();
}
test "ProgressBar styles" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
// Test different styles
_ = barEx(&ctx, 0.75, .{ .style = .solid });
_ = barEx(&ctx, 0.75, .{ .style = .striped });
_ = barEx(&ctx, 0.75, .{ .style = .gradient });
_ = barEx(&ctx, 0.75, .{ .style = .segmented, .segments = 5 });
try std.testing.expect(ctx.commands.items.len > 0);
ctx.endFrame();
}
test "ProgressCircle basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = circle(&ctx, 0.75);
try std.testing.expectEqual(@as(f32, 0.75), result.progress);
ctx.endFrame();
}
test "Spinner state" {
var state = SpinnerState{};
// Initial state
try std.testing.expectEqual(@as(f32, 0), state.animation);
// Update advances animation
state.update(1.0);
state.last_update -= 100; // Simulate 100ms passed
state.update(1.0);
try std.testing.expect(state.animation >= 0);
}
test "Spinner basic" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
var state = SpinnerState{};
const result = spinner(&ctx, &state);
try std.testing.expect(result.bounds.w > 0);
ctx.endFrame();
}

View file

@ -0,0 +1,302 @@
//! Progress Render - Drawing helper functions
//!
//! Shared drawing functions for progress widgets.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
const Rect = Layout.Rect;
const Color = Style.Color;
pub fn drawStripedFill(ctx: *Context, bounds: Rect, fill_color: Color, animated: bool) void {
_ = animated; // TODO: Use frame time for animation offset
if (bounds.w == 0 or bounds.h == 0) return;
// Draw base fill
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y,
.w = bounds.w,
.h = bounds.h,
.color = fill_color,
},
});
// Draw stripes (darker lines)
const stripe_color = Color.rgba(
fill_color.r -| 30,
fill_color.g -| 30,
fill_color.b -| 30,
fill_color.a,
);
const stripe_width: i32 = 6;
const stripe_gap: i32 = 12;
var x = bounds.x;
while (x < bounds.x + @as(i32, @intCast(bounds.w))) {
const stripe_h = @min(@as(u32, @intCast(@max(0, bounds.x + @as(i32, @intCast(bounds.w)) - x))), @as(u32, @intCast(stripe_width)));
if (stripe_h > 0) {
ctx.pushCommand(.{
.rect = .{
.x = x,
.y = bounds.y,
.w = stripe_h,
.h = bounds.h,
.color = stripe_color,
},
});
}
x += stripe_gap;
}
}
pub fn drawGradientFill(ctx: *Context, bounds: Rect, base_color: Color, vertical: bool) void {
if (bounds.w == 0 or bounds.h == 0) return;
// Simple gradient approximation with 4 bands
const bands: u32 = 4;
const steps = if (vertical) bounds.h / bands else bounds.w / bands;
var i: u32 = 0;
while (i < bands) : (i += 1) {
const t: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bands));
const brightness: u8 = @intFromFloat(t * 40);
const band_color = Color.rgba(
base_color.r -| brightness,
base_color.g -| brightness,
base_color.b -| brightness,
base_color.a,
);
if (vertical) {
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = bounds.y + @as(i32, @intCast(i * steps)),
.w = bounds.w,
.h = steps,
.color = band_color,
},
});
} else {
ctx.pushCommand(.{
.rect = .{
.x = bounds.x + @as(i32, @intCast(i * steps)),
.y = bounds.y,
.w = steps,
.h = bounds.h,
.color = band_color,
},
});
}
}
}
pub fn drawSegmentedFill(ctx: *Context, bounds: Rect, fill_color: Color, progress: f32, segments: u8, vertical: bool) void {
const seg_count: u32 = segments;
const gap: u32 = 2;
if (vertical) {
const seg_height = (bounds.h - (seg_count - 1) * gap) / seg_count;
const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress);
var i: u32 = 0;
while (i < seg_count) : (i += 1) {
const seg_y = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast((i + 1) * (seg_height + gap)));
const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a);
ctx.pushCommand(.{
.rect = .{
.x = bounds.x,
.y = seg_y,
.w = bounds.w,
.h = seg_height,
.color = color,
},
});
}
} else {
const seg_width = (bounds.w - (seg_count - 1) * gap) / seg_count;
const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress);
var i: u32 = 0;
while (i < seg_count) : (i += 1) {
const seg_x = bounds.x + @as(i32, @intCast(i * (seg_width + gap)));
const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a);
ctx.pushCommand(.{
.rect = .{
.x = seg_x,
.y = bounds.y,
.w = seg_width,
.h = bounds.h,
.color = color,
},
});
}
}
}
pub fn drawCircleOutline(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color) void {
// Approximate circle with octagon for simplicity in software rendering
const r = radius;
const s: i32 = @intCast(stroke);
// Draw 8 segments around the circle
const offsets = [_][2]i32{
.{ 0, -r }, // top
.{ r, 0 }, // right
.{ 0, r }, // bottom
.{ -r, 0 }, // left
};
for (offsets) |off| {
ctx.pushCommand(.{
.rect = .{
.x = cx + off[0] - @divTrunc(s, 2),
.y = cy + off[1] - @divTrunc(s, 2),
.w = @intCast(s),
.h = @intCast(s),
.color = color,
},
});
}
}
pub fn drawProgressArc(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color, progress: f32, start_angle: f32) void {
_ = start_angle;
// Simplified arc drawing - draw filled segments
const segments: u32 = 16;
const filled: u32 = @intFromFloat(@as(f32, @floatFromInt(segments)) * progress);
var i: u32 = 0;
while (i < filled) : (i += 1) {
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
ctx.pushCommand(.{
.rect = .{
.x = px - @divTrunc(@as(i32, stroke), 2),
.y = py - @divTrunc(@as(i32, stroke), 2),
.w = stroke,
.h = stroke,
.color = color,
},
});
}
}
pub fn drawRotatingArc(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void {
const segments: u32 = 8;
const arc_length: u32 = 5; // Number of segments in the arc
const start_seg: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments)));
var i: u32 = 0;
while (i < arc_length) : (i += 1) {
const seg = (start_seg + i) % segments;
const angle: f32 = (@as(f32, @floatFromInt(seg)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
// Fade based on position in arc
const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(arc_length - i)) / @as(f32, @floatFromInt(arc_length))) * 255);
const faded = Color.rgba(color.r, color.g, color.b, alpha);
ctx.pushCommand(.{
.rect = .{
.x = px - 2,
.y = py - 2,
.w = 4,
.h = 4,
.color = faded,
},
});
}
}
pub fn drawPulsingDots(ctx: *Context, cx: i32, cy: i32, size: u16, color: Color, count: u8, animation: f32) void {
const radius: f32 = @as(f32, @floatFromInt(size)) / 3.0;
var i: u8 = 0;
while (i < count) : (i += 1) {
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * radius));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * radius));
// Pulse based on animation and position
const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const scale: f32 = 0.5 + 0.5 * @sin(phase * 2.0 * std.math.pi);
const dot_size: u32 = @intFromFloat(2.0 + scale * 3.0);
ctx.pushCommand(.{
.rect = .{
.x = px - @as(i32, @intCast(dot_size / 2)),
.y = py - @as(i32, @intCast(dot_size / 2)),
.w = dot_size,
.h = dot_size,
.color = color,
},
});
}
}
pub fn drawBouncingBars(ctx: *Context, bounds: Rect, color: Color, count: u8, animation: f32) void {
const bar_width = bounds.w / @as(u32, count);
const max_height = bounds.h;
var i: u8 = 0;
while (i < count) : (i += 1) {
// Each bar bounces with phase offset
const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const bounce: f32 = @abs(@sin(phase * 2.0 * std.math.pi));
const bar_height: u32 = @intFromFloat(@as(f32, @floatFromInt(max_height)) * (0.3 + 0.7 * bounce));
const bar_x = bounds.x + @as(i32, @intCast(@as(u32, i) * bar_width));
const bar_y = bounds.y + @as(i32, @intCast(max_height - bar_height));
ctx.pushCommand(.{
.rect = .{
.x = bar_x + 1,
.y = bar_y,
.w = bar_width -| 2,
.h = bar_height,
.color = color,
},
});
}
}
pub fn drawRingWithGap(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void {
const segments: u32 = 12;
const gap_size: u32 = 3; // Number of segments for the gap
const gap_start: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments)));
var i: u32 = 0;
while (i < segments) : (i += 1) {
// Skip gap segments
const distance_from_gap = @min((i + segments - gap_start) % segments, (gap_start + segments - i) % segments);
if (distance_from_gap < gap_size / 2) continue;
const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0;
const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius))));
const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius))));
ctx.pushCommand(.{
.rect = .{
.x = px - 2,
.y = py - 2,
.w = 4,
.h = 4,
.color = color,
},
});
}
}

View file

@ -0,0 +1,141 @@
//! Spinner Widget - Animated loading indicator
//!
//! Part of the progress widget module.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
const Rect = Layout.Rect;
const Color = Style.Color;
const render = @import("render.zig");
// =============================================================================
// Types
// =============================================================================
/// Spinner style
pub const SpinnerStyle = enum {
/// Rotating arc
circular,
/// Pulsing dots
dots,
/// Bouncing bars (equalizer)
bars,
/// Ring with gap
ring,
};
/// Spinner configuration
pub const SpinnerConfig = struct {
/// Visual style
style: SpinnerStyle = .circular,
/// Size in pixels
size: u16 = 24,
/// Animation speed multiplier
speed: f32 = 1.0,
/// Optional label below spinner
label: ?[]const u8 = null,
/// Primary color
color: ?Color = null,
/// Number of elements (dots or bars)
elements: u8 = 8,
};
/// Spinner state (for animation)
pub const SpinnerState = struct {
/// Animation progress (0-1, loops)
animation: f32 = 0,
/// Last update timestamp
last_update: i64 = 0,
pub fn update(self: *SpinnerState, speed: f32) void {
const now = std.time.milliTimestamp();
if (self.last_update == 0) {
self.last_update = now;
return;
}
const delta_ms = now - self.last_update;
self.last_update = now;
// Advance animation
const delta_f: f32 = @floatFromInt(delta_ms);
self.animation += (delta_f / 1000.0) * speed;
if (self.animation >= 1.0) {
self.animation -= 1.0;
}
}
};
/// Spinner result
pub const SpinnerResult = struct {
bounds: Rect,
};
// =============================================================================
// Public API
// =============================================================================
/// Simple spinner
pub fn spinner(ctx: *Context, state: *SpinnerState) SpinnerResult {
return spinnerEx(ctx, state, .{});
}
/// Spinner with configuration
pub fn spinnerEx(ctx: *Context, state: *SpinnerState, config: SpinnerConfig) SpinnerResult {
const theme = Style.currentTheme();
const color = config.color orelse theme.primary;
// Update animation
state.update(config.speed);
// Get bounds
const layout_rect = ctx.layout.area;
const bounds = Rect.init(
layout_rect.x,
layout_rect.y,
config.size,
config.size,
);
const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2));
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
switch (config.style) {
.circular => {
render.drawRotatingArc(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation);
},
.dots => {
render.drawPulsingDots(ctx, center_x, center_y, config.size, color, config.elements, state.animation);
},
.bars => {
render.drawBouncingBars(ctx, bounds, color, config.elements, state.animation);
},
.ring => {
render.drawRingWithGap(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation);
},
}
// Draw label if present
if (config.label) |label| {
const text_x = bounds.x;
const text_y = bounds.y + @as(i32, @intCast(bounds.h)) + 4;
ctx.pushCommand(.{
.text = .{
.x = text_x,
.y = text_y,
.text = label,
.color = theme.text_secondary,
},
});
}
ctx.countWidget();
return .{
.bounds = bounds,
};
}

View file

@ -1,882 +0,0 @@
//! 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;
// Generate unique ID for this widget based on buffer memory address
const widget_id: u64 = @intFromPtr(state.buffer.ptr);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
// 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) {
// Request focus through the focus system
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if this widget has focus
const has_focus = ctx.hasFocus(widget_id);
state.focused = has_focus;
// Get colors
const bg_color = if (has_focus) colors.background.lighten(5) else colors.background;
const border_color = if (has_focus) 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));
}

View file

@ -0,0 +1,85 @@
//! TextArea Render - Drawing helper functions
//!
//! Part of the textarea widget module.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Command = @import("../../core/command.zig");
const Style = @import("../../core/style.zig");
/// Draw a line number
pub 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
pub 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
pub 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 (for line number width calculation)
pub 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;
}

View file

@ -0,0 +1,380 @@
//! TextArea State - State management for multi-line text editor
//!
//! Part of the textarea widget module.
const std = @import("std");
/// 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,
const Self = @This();
/// Initialize with empty buffer
pub fn init(buffer: []u8) Self {
return .{ .buffer = buffer };
}
/// Get the current text
pub fn text(self: Self) []const u8 {
return self.buffer[0..self.len];
}
/// Set text programmatically
pub fn setText(self: *Self, 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: *Self) 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: *Self, 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: *Self) void {
self.insert("\n");
}
/// Delete character before cursor (backspace)
pub fn deleteBack(self: *Self) 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: *Self) 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: *Self) 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: Self) 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
pub fn getLineStart(self: Self, 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)
pub fn getLineEnd(self: Self, 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: Self) 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self) void {
self.selection_start = 0;
self.cursor = self.len;
}
/// Ensure cursor is visible by adjusting scroll
pub fn ensureCursorVisible(self: *Self, 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;
}
}
};

View file

@ -0,0 +1,396 @@
//! 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.
//!
//! This module re-exports types from the textarea/ subdirectory.
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");
// Re-export types
pub const types = @import("types.zig");
pub const TextAreaConfig = types.TextAreaConfig;
pub const TextAreaColors = types.TextAreaColors;
pub const TextAreaResult = types.TextAreaResult;
// Re-export state
pub const state = @import("state.zig");
pub const TextAreaState = state.TextAreaState;
// Import render helpers
const render = @import("render.zig");
// =============================================================================
// Public API
// =============================================================================
/// Draw a text area and return interaction result
pub fn textArea(ctx: *Context, textarea_state: *TextAreaState) TextAreaResult {
return textAreaEx(ctx, textarea_state, .{}, .{});
}
/// Draw a text area with custom configuration
pub fn textAreaEx(
ctx: *Context,
textarea_state: *TextAreaState,
config: TextAreaConfig,
colors: TextAreaColors,
) TextAreaResult {
const bounds = ctx.layout.nextRect();
return textAreaRect(ctx, bounds, textarea_state, config, colors);
}
/// Draw a text area in a specific rectangle
pub fn textAreaRect(
ctx: *Context,
bounds: Layout.Rect,
textarea_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;
// Generate unique ID for this widget based on buffer memory address
const widget_id: u64 = @intFromPtr(textarea_state.buffer.ptr);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
// 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) {
// Request focus through the focus system
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if this widget has focus
const has_focus = ctx.hasFocus(widget_id);
textarea_state.focused = has_focus;
// Get colors
const bg_color = if (has_focus) colors.background.lighten(5) else colors.background;
const border_color = if (has_focus) 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(render.countDigits(textarea_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 (textarea_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], ' ');
textarea_state.insert(spaces[0..count]);
} else {
textarea_state.insert(&[_]u8{c});
}
}
result.changed = true;
}
}
// Ensure cursor is visible
textarea_state.ensureCursorVisible(visible_lines, visible_cols);
// Get cursor position
const cursor_pos = textarea_state.getCursorPosition();
result.cursor_line = cursor_pos.line;
result.cursor_col = cursor_pos.col;
// Draw text line by line
const txt = textarea_state.text();
var line_num: usize = 0;
var line_start: usize = 0;
for (txt, 0..) |c, i| {
if (c == '\n') {
if (line_num >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) {
const draw_line = line_num - textarea_state.scroll_y;
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
// Draw line number
if (config.line_numbers) {
render.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];
render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text);
// Draw selection on this line
if (textarea_state.selection_start != null) {
render.drawLineSelection(
ctx,
text_area.x,
y,
line_start,
i,
textarea_state.cursor,
textarea_state.selection_start.?,
textarea_state.scroll_x,
visible_cols,
char_width,
line_height,
colors.selection,
);
}
// Draw cursor if on this line
if (textarea_state.focused and cursor_pos.line == line_num) {
const cursor_x_pos = cursor_pos.col -| textarea_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 >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) {
const draw_line = line_num - textarea_state.scroll_y;
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
// Draw line number
if (config.line_numbers) {
render.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 "";
render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text);
// Draw selection on this line
if (textarea_state.selection_start != null) {
render.drawLineSelection(
ctx,
text_area.x,
y,
line_start,
txt.len,
textarea_state.cursor,
textarea_state.selection_start.?,
textarea_state.scroll_x,
visible_cols,
char_width,
line_height,
colors.selection,
);
}
// Draw cursor if on this line
if (textarea_state.focused and cursor_pos.line == line_num) {
const cursor_x_pos = cursor_pos.col -| textarea_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 (textarea_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;
}
// =============================================================================
// Tests
// =============================================================================
test "TextAreaState insert" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello");
try std.testing.expectEqualStrings("Hello", textarea_state.text());
try std.testing.expectEqual(@as(usize, 5), textarea_state.cursor);
textarea_state.insertNewline();
textarea_state.insert("World");
try std.testing.expectEqualStrings("Hello\nWorld", textarea_state.text());
}
test "TextAreaState line count" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Line 1");
try std.testing.expectEqual(@as(usize, 1), textarea_state.lineCount());
textarea_state.insertNewline();
textarea_state.insert("Line 2");
try std.testing.expectEqual(@as(usize, 2), textarea_state.lineCount());
textarea_state.insertNewline();
textarea_state.insertNewline();
textarea_state.insert("Line 4");
try std.testing.expectEqual(@as(usize, 4), textarea_state.lineCount());
}
test "TextAreaState cursor position" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello\nWorld\nTest");
// Cursor at end
const pos = textarea_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 textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Line 1\nLine 2\nLine 3");
// Move up
textarea_state.cursorUp(false);
var pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 1), pos.line);
textarea_state.cursorUp(false);
pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 0), pos.line);
// Move down
textarea_state.cursorDown(false);
pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 1), pos.line);
}
test "TextAreaState home/end" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello World");
textarea_state.cursorHome(false);
try std.testing.expectEqual(@as(usize, 0), textarea_state.cursor);
textarea_state.cursorEnd(false);
try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor);
}
test "TextAreaState selection" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello World");
textarea_state.selectAll();
try std.testing.expectEqual(@as(?usize, 0), textarea_state.selection_start);
try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor);
textarea_state.insert("X");
try std.testing.expectEqualStrings("X", textarea_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 textarea_state = TextAreaState.init(&buf);
ctx.beginFrame();
ctx.layout.row_height = 100;
_ = textArea(&ctx, &textarea_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), render.countDigits(0));
try std.testing.expectEqual(@as(usize, 1), render.countDigits(5));
try std.testing.expectEqual(@as(usize, 2), render.countDigits(10));
try std.testing.expectEqual(@as(usize, 3), render.countDigits(100));
try std.testing.expectEqual(@as(usize, 4), render.countDigits(1234));
}

View file

@ -0,0 +1,59 @@
//! TextArea Types - Configuration and result types
//!
//! Part of the textarea widget module.
const Style = @import("../../core/style.zig");
/// 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,
};

View file

@ -24,10 +24,10 @@ pub const scroll = @import("scroll.zig");
pub const menu = @import("menu.zig"); pub const menu = @import("menu.zig");
pub const tabs = @import("tabs.zig"); pub const tabs = @import("tabs.zig");
pub const radio = @import("radio.zig"); pub const radio = @import("radio.zig");
pub const progress = @import("progress.zig"); pub const progress = @import("progress/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 textarea = @import("textarea/textarea.zig");
pub const tree = @import("tree.zig"); pub const tree = @import("tree.zig");
pub const badge = @import("badge.zig"); pub const badge = @import("badge.zig");
pub const img = @import("image.zig"); pub const img = @import("image.zig");