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:
parent
cfe4ee7935
commit
59935aeb2b
12 changed files with 1813 additions and 1690 deletions
|
|
@ -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();
|
|
||||||
}
|
|
||||||
183
src/widgets/progress/bar.zig
Normal file
183
src/widgets/progress/bar.zig
Normal 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;
|
||||||
|
}
|
||||||
114
src/widgets/progress/circle.zig
Normal file
114
src/widgets/progress/circle.zig
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
151
src/widgets/progress/progress.zig
Normal file
151
src/widgets/progress/progress.zig
Normal 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();
|
||||||
|
}
|
||||||
302
src/widgets/progress/render.zig
Normal file
302
src/widgets/progress/render.zig
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/widgets/progress/spinner.zig
Normal file
141
src/widgets/progress/spinner.zig
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
85
src/widgets/textarea/render.zig
Normal file
85
src/widgets/textarea/render.zig
Normal 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;
|
||||||
|
}
|
||||||
380
src/widgets/textarea/state.zig
Normal file
380
src/widgets/textarea/state.zig
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
396
src/widgets/textarea/textarea.zig
Normal file
396
src/widgets/textarea/textarea.zig
Normal 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));
|
||||||
|
}
|
||||||
59
src/widgets/textarea/types.zig
Normal file
59
src/widgets/textarea/types.zig
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue