feat: Añadir archivos nuevos de widgets y focus
Archivos que estaban localmente pero no en el repo: - src/core/focus_group.zig (416 líneas) - src/widgets/focus.zig (272 líneas) - src/widgets/progress.zig (806 líneas) - src/widgets/table.zig (1592 líneas) - src/widgets/textarea.zig (871 líneas) 🤖 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
aa3d0edcff
commit
1ee10b8e17
5 changed files with 3957 additions and 0 deletions
416
src/core/focus_group.zig
Normal file
416
src/core/focus_group.zig
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
//! Focus Groups
|
||||
//!
|
||||
//! Manages focus navigation within groups of widgets.
|
||||
//! Supports tab order, wrapping, and nested groups.
|
||||
|
||||
const std = @import("std");
|
||||
const Input = @import("input.zig");
|
||||
|
||||
/// Maximum widgets per group
|
||||
const MAX_GROUP_SIZE = 64;
|
||||
|
||||
/// Maximum number of groups
|
||||
const MAX_GROUPS = 32;
|
||||
|
||||
/// Focus direction
|
||||
pub const Direction = enum {
|
||||
next,
|
||||
previous,
|
||||
up,
|
||||
down,
|
||||
left,
|
||||
right,
|
||||
};
|
||||
|
||||
/// A focus group containing related widgets
|
||||
pub const FocusGroup = struct {
|
||||
/// Group identifier
|
||||
id: u64,
|
||||
/// Widget IDs in this group (in tab order)
|
||||
widgets: [MAX_GROUP_SIZE]u64 = undefined,
|
||||
/// Number of widgets
|
||||
count: usize = 0,
|
||||
/// Currently focused widget index
|
||||
focused_index: ?usize = null,
|
||||
/// Wrap focus at boundaries
|
||||
wrap: bool = true,
|
||||
/// Is this group active
|
||||
active: bool = true,
|
||||
/// Parent group (for nested focus)
|
||||
parent_group: ?u64 = null,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Create a new focus group
|
||||
pub fn init(id: u64) Self {
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
/// Add a widget to the group
|
||||
pub fn add(self: *Self, widget_id: u64) void {
|
||||
if (self.count >= MAX_GROUP_SIZE) return;
|
||||
self.widgets[self.count] = widget_id;
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
/// Remove a widget from the group
|
||||
pub fn remove(self: *Self, widget_id: u64) void {
|
||||
var i: usize = 0;
|
||||
while (i < self.count) {
|
||||
if (self.widgets[i] == widget_id) {
|
||||
// Shift remaining widgets
|
||||
var j = i;
|
||||
while (j < self.count - 1) : (j += 1) {
|
||||
self.widgets[j] = self.widgets[j + 1];
|
||||
}
|
||||
self.count -= 1;
|
||||
|
||||
// Adjust focused index
|
||||
if (self.focused_index) |idx| {
|
||||
if (idx == i) {
|
||||
self.focused_index = if (self.count > 0) @min(idx, self.count - 1) else null;
|
||||
} else if (idx > i) {
|
||||
self.focused_index = idx - 1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently focused widget ID
|
||||
pub fn getFocused(self: Self) ?u64 {
|
||||
if (self.focused_index) |idx| {
|
||||
if (idx < self.count) {
|
||||
return self.widgets[idx];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Set focus to a specific widget
|
||||
pub fn setFocus(self: *Self, widget_id: u64) bool {
|
||||
for (self.widgets[0..self.count], 0..) |id, i| {
|
||||
if (id == widget_id) {
|
||||
self.focused_index = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Focus the next widget
|
||||
pub fn focusNext(self: *Self) ?u64 {
|
||||
if (self.count == 0) return null;
|
||||
|
||||
if (self.focused_index) |idx| {
|
||||
if (idx + 1 < self.count) {
|
||||
self.focused_index = idx + 1;
|
||||
} else if (self.wrap) {
|
||||
self.focused_index = 0;
|
||||
}
|
||||
} else {
|
||||
self.focused_index = 0;
|
||||
}
|
||||
|
||||
return self.getFocused();
|
||||
}
|
||||
|
||||
/// Focus the previous widget
|
||||
pub fn focusPrevious(self: *Self) ?u64 {
|
||||
if (self.count == 0) return null;
|
||||
|
||||
if (self.focused_index) |idx| {
|
||||
if (idx > 0) {
|
||||
self.focused_index = idx - 1;
|
||||
} else if (self.wrap) {
|
||||
self.focused_index = self.count - 1;
|
||||
}
|
||||
} else {
|
||||
self.focused_index = self.count - 1;
|
||||
}
|
||||
|
||||
return self.getFocused();
|
||||
}
|
||||
|
||||
/// Focus first widget
|
||||
pub fn focusFirst(self: *Self) ?u64 {
|
||||
if (self.count == 0) return null;
|
||||
self.focused_index = 0;
|
||||
return self.getFocused();
|
||||
}
|
||||
|
||||
/// Focus last widget
|
||||
pub fn focusLast(self: *Self) ?u64 {
|
||||
if (self.count == 0) return null;
|
||||
self.focused_index = self.count - 1;
|
||||
return self.getFocused();
|
||||
}
|
||||
|
||||
/// Clear focus
|
||||
pub fn clearFocus(self: *Self) void {
|
||||
self.focused_index = null;
|
||||
}
|
||||
|
||||
/// Check if a widget has focus
|
||||
pub fn hasFocus(self: Self, widget_id: u64) bool {
|
||||
if (self.getFocused()) |focused| {
|
||||
return focused == widget_id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get index of a widget
|
||||
pub fn indexOf(self: Self, widget_id: u64) ?usize {
|
||||
for (self.widgets[0..self.count], 0..) |id, i| {
|
||||
if (id == widget_id) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Focus Group Manager - manages multiple focus groups
|
||||
pub const FocusGroupManager = struct {
|
||||
groups: [MAX_GROUPS]FocusGroup = undefined,
|
||||
group_count: usize = 0,
|
||||
active_group: ?u64 = null,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Initialize the manager
|
||||
pub fn init() Self {
|
||||
return .{};
|
||||
}
|
||||
|
||||
/// Create a new group
|
||||
pub fn createGroup(self: *Self, id: u64) *FocusGroup {
|
||||
if (self.group_count >= MAX_GROUPS) {
|
||||
// Return first group as fallback
|
||||
return &self.groups[0];
|
||||
}
|
||||
|
||||
self.groups[self.group_count] = FocusGroup.init(id);
|
||||
const group = &self.groups[self.group_count];
|
||||
self.group_count += 1;
|
||||
|
||||
// Set as active if first group
|
||||
if (self.active_group == null) {
|
||||
self.active_group = id;
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// Get a group by ID
|
||||
pub fn getGroup(self: *Self, id: u64) ?*FocusGroup {
|
||||
for (self.groups[0..self.group_count]) |*group| {
|
||||
if (group.id == id) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Remove a group
|
||||
pub fn removeGroup(self: *Self, id: u64) void {
|
||||
var i: usize = 0;
|
||||
while (i < self.group_count) {
|
||||
if (self.groups[i].id == id) {
|
||||
var j = i;
|
||||
while (j < self.group_count - 1) : (j += 1) {
|
||||
self.groups[j] = self.groups[j + 1];
|
||||
}
|
||||
self.group_count -= 1;
|
||||
|
||||
if (self.active_group == id) {
|
||||
self.active_group = if (self.group_count > 0) self.groups[0].id else null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active group
|
||||
pub fn setActiveGroup(self: *Self, id: u64) void {
|
||||
if (self.getGroup(id) != null) {
|
||||
self.active_group = id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the active group
|
||||
pub fn getActiveGroup(self: *Self) ?*FocusGroup {
|
||||
if (self.active_group) |id| {
|
||||
return self.getGroup(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handle focus navigation input
|
||||
pub fn handleInput(self: *Self, input: *const Input.InputState) ?u64 {
|
||||
const group = self.getActiveGroup() orelse return null;
|
||||
|
||||
if (input.keyPressed(.tab)) {
|
||||
if (input.keyDown(.left_shift) or input.keyDown(.right_shift)) {
|
||||
return group.focusPrevious();
|
||||
} else {
|
||||
return group.focusNext();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get currently focused widget across all groups
|
||||
pub fn getGlobalFocus(self: Self) ?u64 {
|
||||
if (self.active_group) |active_id| {
|
||||
for (self.groups[0..self.group_count]) |group| {
|
||||
if (group.id == active_id) {
|
||||
return group.getFocused();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Focus next group
|
||||
pub fn focusNextGroup(self: *Self) void {
|
||||
if (self.group_count <= 1) return;
|
||||
|
||||
if (self.active_group) |active_id| {
|
||||
for (self.groups[0..self.group_count], 0..) |group, i| {
|
||||
if (group.id == active_id) {
|
||||
const next_idx = (i + 1) % self.group_count;
|
||||
self.active_group = self.groups[next_idx].id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus previous group
|
||||
pub fn focusPreviousGroup(self: *Self) void {
|
||||
if (self.group_count <= 1) return;
|
||||
|
||||
if (self.active_group) |active_id| {
|
||||
for (self.groups[0..self.group_count], 0..) |group, i| {
|
||||
if (group.id == active_id) {
|
||||
const prev_idx = if (i == 0) self.group_count - 1 else i - 1;
|
||||
self.active_group = self.groups[prev_idx].id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "FocusGroup init" {
|
||||
const group = FocusGroup.init(1);
|
||||
try std.testing.expectEqual(@as(u64, 1), group.id);
|
||||
try std.testing.expectEqual(@as(usize, 0), group.count);
|
||||
}
|
||||
|
||||
test "FocusGroup add and remove" {
|
||||
var group = FocusGroup.init(1);
|
||||
|
||||
group.add(100);
|
||||
group.add(200);
|
||||
group.add(300);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 3), group.count);
|
||||
|
||||
group.remove(200);
|
||||
try std.testing.expectEqual(@as(usize, 2), group.count);
|
||||
try std.testing.expectEqual(@as(u64, 100), group.widgets[0]);
|
||||
try std.testing.expectEqual(@as(u64, 300), group.widgets[1]);
|
||||
}
|
||||
|
||||
test "FocusGroup navigation" {
|
||||
var group = FocusGroup.init(1);
|
||||
group.add(100);
|
||||
group.add(200);
|
||||
group.add(300);
|
||||
|
||||
// Focus first
|
||||
try std.testing.expectEqual(@as(?u64, 100), group.focusFirst());
|
||||
try std.testing.expectEqual(@as(?usize, 0), group.focused_index);
|
||||
|
||||
// Focus next
|
||||
try std.testing.expectEqual(@as(?u64, 200), group.focusNext());
|
||||
try std.testing.expectEqual(@as(?u64, 300), group.focusNext());
|
||||
|
||||
// Wrap around
|
||||
try std.testing.expectEqual(@as(?u64, 100), group.focusNext());
|
||||
|
||||
// Focus previous
|
||||
try std.testing.expectEqual(@as(?u64, 300), group.focusPrevious());
|
||||
}
|
||||
|
||||
test "FocusGroup setFocus" {
|
||||
var group = FocusGroup.init(1);
|
||||
group.add(100);
|
||||
group.add(200);
|
||||
group.add(300);
|
||||
|
||||
try std.testing.expect(group.setFocus(200));
|
||||
try std.testing.expectEqual(@as(?u64, 200), group.getFocused());
|
||||
|
||||
try std.testing.expect(!group.setFocus(999)); // Non-existent
|
||||
}
|
||||
|
||||
test "FocusGroup hasFocus" {
|
||||
var group = FocusGroup.init(1);
|
||||
group.add(100);
|
||||
group.add(200);
|
||||
_ = group.focusFirst();
|
||||
|
||||
try std.testing.expect(group.hasFocus(100));
|
||||
try std.testing.expect(!group.hasFocus(200));
|
||||
}
|
||||
|
||||
test "FocusGroupManager create groups" {
|
||||
var manager = FocusGroupManager.init();
|
||||
|
||||
const group1 = manager.createGroup(1);
|
||||
const group2 = manager.createGroup(2);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), manager.group_count);
|
||||
try std.testing.expectEqual(@as(u64, 1), group1.id);
|
||||
try std.testing.expectEqual(@as(u64, 2), group2.id);
|
||||
|
||||
// First group should be active
|
||||
try std.testing.expectEqual(@as(?u64, 1), manager.active_group);
|
||||
}
|
||||
|
||||
test "FocusGroupManager get and remove" {
|
||||
var manager = FocusGroupManager.init();
|
||||
_ = manager.createGroup(1);
|
||||
_ = manager.createGroup(2);
|
||||
|
||||
const group = manager.getGroup(2);
|
||||
try std.testing.expect(group != null);
|
||||
try std.testing.expectEqual(@as(u64, 2), group.?.id);
|
||||
|
||||
manager.removeGroup(1);
|
||||
try std.testing.expectEqual(@as(usize, 1), manager.group_count);
|
||||
try std.testing.expect(manager.getGroup(1) == null);
|
||||
}
|
||||
|
||||
test "FocusGroupManager active group" {
|
||||
var manager = FocusGroupManager.init();
|
||||
_ = manager.createGroup(1);
|
||||
_ = manager.createGroup(2);
|
||||
|
||||
manager.setActiveGroup(2);
|
||||
try std.testing.expectEqual(@as(?u64, 2), manager.active_group);
|
||||
|
||||
manager.focusNextGroup();
|
||||
try std.testing.expectEqual(@as(?u64, 1), manager.active_group);
|
||||
}
|
||||
272
src/widgets/focus.zig
Normal file
272
src/widgets/focus.zig
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
//! Focus Management - Track and navigate widget focus
|
||||
//!
|
||||
//! Manages which widget has keyboard focus and provides
|
||||
//! Tab/Shift+Tab navigation between focusable widgets.
|
||||
|
||||
const std = @import("std");
|
||||
const Input = @import("../core/input.zig");
|
||||
|
||||
/// Maximum number of focusable widgets per frame
|
||||
pub const MAX_FOCUSABLES = 64;
|
||||
|
||||
/// Focus manager state
|
||||
pub const FocusManager = struct {
|
||||
/// Currently focused widget ID
|
||||
focused_id: ?u32 = null,
|
||||
|
||||
/// List of focusable widget IDs this frame (in order)
|
||||
focusables: [MAX_FOCUSABLES]u32 = undefined,
|
||||
focusable_count: usize = 0,
|
||||
|
||||
/// Widget ID to focus next frame (from keyboard nav)
|
||||
pending_focus: ?u32 = null,
|
||||
|
||||
/// Whether Tab was pressed this frame
|
||||
tab_pressed: bool = false,
|
||||
shift_tab_pressed: bool = false,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Reset for new frame
|
||||
pub fn beginFrame(self: *Self) void {
|
||||
self.focusable_count = 0;
|
||||
self.tab_pressed = false;
|
||||
self.shift_tab_pressed = false;
|
||||
|
||||
// Apply pending focus
|
||||
if (self.pending_focus) |id| {
|
||||
self.focused_id = id;
|
||||
self.pending_focus = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process keyboard input for focus navigation
|
||||
pub fn processInput(self: *Self, input: *const Input.InputState, key_events: []const Input.KeyEvent) void {
|
||||
_ = input;
|
||||
for (key_events) |event| {
|
||||
if (event.key == .tab and event.pressed) {
|
||||
if (event.modifiers.shift) {
|
||||
self.shift_tab_pressed = true;
|
||||
} else {
|
||||
self.tab_pressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a widget as focusable
|
||||
pub fn registerFocusable(self: *Self, id: u32) void {
|
||||
if (self.focusable_count >= MAX_FOCUSABLES) return;
|
||||
self.focusables[self.focusable_count] = id;
|
||||
self.focusable_count += 1;
|
||||
}
|
||||
|
||||
/// Check if a widget has focus
|
||||
pub fn hasFocus(self: Self, id: u32) bool {
|
||||
return self.focused_id == id;
|
||||
}
|
||||
|
||||
/// Request focus for a widget
|
||||
pub fn requestFocus(self: *Self, id: u32) void {
|
||||
self.focused_id = id;
|
||||
}
|
||||
|
||||
/// Clear focus
|
||||
pub fn clearFocus(self: *Self) void {
|
||||
self.focused_id = null;
|
||||
}
|
||||
|
||||
/// End of frame: process Tab navigation
|
||||
pub fn endFrame(self: *Self) void {
|
||||
if (self.focusable_count == 0) return;
|
||||
|
||||
if (self.tab_pressed) {
|
||||
self.focusNext();
|
||||
} else if (self.shift_tab_pressed) {
|
||||
self.focusPrev();
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus next widget in order
|
||||
fn focusNext(self: *Self) void {
|
||||
if (self.focusable_count == 0) return;
|
||||
|
||||
if (self.focused_id) |current| {
|
||||
// Find current index
|
||||
for (self.focusables[0..self.focusable_count], 0..) |id, i| {
|
||||
if (id == current) {
|
||||
// Focus next (wrap around)
|
||||
const next_idx = (i + 1) % self.focusable_count;
|
||||
self.pending_focus = self.focusables[next_idx];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No current focus, focus first
|
||||
self.pending_focus = self.focusables[0];
|
||||
}
|
||||
|
||||
/// Focus previous widget in order
|
||||
fn focusPrev(self: *Self) void {
|
||||
if (self.focusable_count == 0) return;
|
||||
|
||||
if (self.focused_id) |current| {
|
||||
// Find current index
|
||||
for (self.focusables[0..self.focusable_count], 0..) |id, i| {
|
||||
if (id == current) {
|
||||
// Focus previous (wrap around)
|
||||
const prev_idx = if (i == 0) self.focusable_count - 1 else i - 1;
|
||||
self.pending_focus = self.focusables[prev_idx];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No current focus, focus last
|
||||
self.pending_focus = self.focusables[self.focusable_count - 1];
|
||||
}
|
||||
|
||||
/// Focus specific index
|
||||
pub fn focusIndex(self: *Self, idx: usize) void {
|
||||
if (idx < self.focusable_count) {
|
||||
self.pending_focus = self.focusables[idx];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the index of the focused widget
|
||||
pub fn focusedIndex(self: Self) ?usize {
|
||||
if (self.focused_id) |current| {
|
||||
for (self.focusables[0..self.focusable_count], 0..) |id, i| {
|
||||
if (id == current) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Focus ring - circular focus navigation helper
|
||||
pub const FocusRing = struct {
|
||||
ids: [MAX_FOCUSABLES]u32 = undefined,
|
||||
count: usize = 0,
|
||||
current: usize = 0,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Add a widget ID to the ring
|
||||
pub fn add(self: *Self, id: u32) void {
|
||||
if (self.count >= MAX_FOCUSABLES) return;
|
||||
self.ids[self.count] = id;
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
/// Get current focused ID
|
||||
pub fn currentId(self: Self) ?u32 {
|
||||
if (self.count == 0) return null;
|
||||
return self.ids[self.current];
|
||||
}
|
||||
|
||||
/// Move to next
|
||||
pub fn next(self: *Self) void {
|
||||
if (self.count == 0) return;
|
||||
self.current = (self.current + 1) % self.count;
|
||||
}
|
||||
|
||||
/// Move to previous
|
||||
pub fn prev(self: *Self) void {
|
||||
if (self.count == 0) return;
|
||||
self.current = if (self.current == 0) self.count - 1 else self.current - 1;
|
||||
}
|
||||
|
||||
/// Check if widget has focus
|
||||
pub fn isFocused(self: Self, id: u32) bool {
|
||||
if (self.count == 0) return false;
|
||||
return self.ids[self.current] == id;
|
||||
}
|
||||
|
||||
/// Focus specific widget by ID
|
||||
pub fn focusId(self: *Self, id: u32) bool {
|
||||
for (self.ids[0..self.count], 0..) |widget_id, i| {
|
||||
if (widget_id == id) {
|
||||
self.current = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Reset the ring
|
||||
pub fn reset(self: *Self) void {
|
||||
self.count = 0;
|
||||
self.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "FocusManager navigation" {
|
||||
var fm = FocusManager{};
|
||||
|
||||
fm.beginFrame();
|
||||
fm.registerFocusable(100);
|
||||
fm.registerFocusable(200);
|
||||
fm.registerFocusable(300);
|
||||
|
||||
// No focus initially
|
||||
try std.testing.expectEqual(@as(?u32, null), fm.focused_id);
|
||||
|
||||
// Tab to first
|
||||
fm.tab_pressed = true;
|
||||
fm.endFrame();
|
||||
fm.beginFrame();
|
||||
|
||||
try std.testing.expectEqual(@as(?u32, 100), fm.focused_id);
|
||||
|
||||
// Register again for new frame
|
||||
fm.registerFocusable(100);
|
||||
fm.registerFocusable(200);
|
||||
fm.registerFocusable(300);
|
||||
|
||||
// Tab to second
|
||||
fm.tab_pressed = true;
|
||||
fm.endFrame();
|
||||
fm.beginFrame();
|
||||
|
||||
try std.testing.expectEqual(@as(?u32, 200), fm.focused_id);
|
||||
}
|
||||
|
||||
test "FocusRing" {
|
||||
var ring = FocusRing{};
|
||||
|
||||
ring.add(10);
|
||||
ring.add(20);
|
||||
ring.add(30);
|
||||
|
||||
try std.testing.expectEqual(@as(?u32, 10), ring.currentId());
|
||||
try std.testing.expect(ring.isFocused(10));
|
||||
|
||||
ring.next();
|
||||
try std.testing.expectEqual(@as(?u32, 20), ring.currentId());
|
||||
|
||||
ring.prev();
|
||||
try std.testing.expectEqual(@as(?u32, 10), ring.currentId());
|
||||
|
||||
ring.prev(); // Wrap to end
|
||||
try std.testing.expectEqual(@as(?u32, 30), ring.currentId());
|
||||
}
|
||||
|
||||
test "FocusRing focusId" {
|
||||
var ring = FocusRing{};
|
||||
|
||||
ring.add(100);
|
||||
ring.add(200);
|
||||
ring.add(300);
|
||||
|
||||
const found = ring.focusId(200);
|
||||
try std.testing.expect(found);
|
||||
try std.testing.expectEqual(@as(?u32, 200), ring.currentId());
|
||||
}
|
||||
806
src/widgets/progress.zig
Normal file
806
src/widgets/progress.zig
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
//! 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();
|
||||
}
|
||||
1592
src/widgets/table.zig
Normal file
1592
src/widgets/table.zig
Normal file
File diff suppressed because it is too large
Load diff
871
src/widgets/textarea.zig
Normal file
871
src/widgets/textarea.zig
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
//! TextArea Widget - Multi-line text editor
|
||||
//!
|
||||
//! A multi-line text input with cursor navigation, selection, and scrolling.
|
||||
//! Supports line wrapping and handles large documents efficiently.
|
||||
|
||||
const std = @import("std");
|
||||
const Context = @import("../core/context.zig").Context;
|
||||
const Command = @import("../core/command.zig");
|
||||
const Layout = @import("../core/layout.zig");
|
||||
const Style = @import("../core/style.zig");
|
||||
const Input = @import("../core/input.zig");
|
||||
|
||||
/// Text area state (caller-managed)
|
||||
pub const TextAreaState = struct {
|
||||
/// Text buffer
|
||||
buffer: []u8,
|
||||
/// Current text length
|
||||
len: usize = 0,
|
||||
/// Cursor position (byte index)
|
||||
cursor: usize = 0,
|
||||
/// Selection start (byte index), null if no selection
|
||||
selection_start: ?usize = null,
|
||||
/// Scroll offset (line number)
|
||||
scroll_y: usize = 0,
|
||||
/// Horizontal scroll offset (chars)
|
||||
scroll_x: usize = 0,
|
||||
/// Whether this input has focus
|
||||
focused: bool = false,
|
||||
|
||||
/// Initialize with empty buffer
|
||||
pub fn init(buffer: []u8) TextAreaState {
|
||||
return .{ .buffer = buffer };
|
||||
}
|
||||
|
||||
/// Get the current text
|
||||
pub fn text(self: TextAreaState) []const u8 {
|
||||
return self.buffer[0..self.len];
|
||||
}
|
||||
|
||||
/// Set text programmatically
|
||||
pub fn setText(self: *TextAreaState, new_text: []const u8) void {
|
||||
const copy_len = @min(new_text.len, self.buffer.len);
|
||||
@memcpy(self.buffer[0..copy_len], new_text[0..copy_len]);
|
||||
self.len = copy_len;
|
||||
self.cursor = copy_len;
|
||||
self.selection_start = null;
|
||||
self.scroll_y = 0;
|
||||
self.scroll_x = 0;
|
||||
}
|
||||
|
||||
/// Clear the text
|
||||
pub fn clear(self: *TextAreaState) void {
|
||||
self.len = 0;
|
||||
self.cursor = 0;
|
||||
self.selection_start = null;
|
||||
self.scroll_y = 0;
|
||||
self.scroll_x = 0;
|
||||
}
|
||||
|
||||
/// Insert text at cursor
|
||||
pub fn insert(self: *TextAreaState, new_text: []const u8) void {
|
||||
// Delete selection first if any
|
||||
self.deleteSelection();
|
||||
|
||||
const available = self.buffer.len - self.len;
|
||||
const to_insert = @min(new_text.len, available);
|
||||
|
||||
if (to_insert == 0) return;
|
||||
|
||||
// Move text after cursor
|
||||
const after_cursor = self.len - self.cursor;
|
||||
if (after_cursor > 0) {
|
||||
std.mem.copyBackwards(
|
||||
u8,
|
||||
self.buffer[self.cursor + to_insert .. self.len + to_insert],
|
||||
self.buffer[self.cursor..self.len],
|
||||
);
|
||||
}
|
||||
|
||||
// Insert new text
|
||||
@memcpy(self.buffer[self.cursor..][0..to_insert], new_text[0..to_insert]);
|
||||
self.len += to_insert;
|
||||
self.cursor += to_insert;
|
||||
}
|
||||
|
||||
/// Insert a newline
|
||||
pub fn insertNewline(self: *TextAreaState) void {
|
||||
self.insert("\n");
|
||||
}
|
||||
|
||||
/// Delete character before cursor (backspace)
|
||||
pub fn deleteBack(self: *TextAreaState) void {
|
||||
if (self.selection_start != null) {
|
||||
self.deleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.cursor == 0) return;
|
||||
|
||||
// Move text after cursor back
|
||||
const after_cursor = self.len - self.cursor;
|
||||
if (after_cursor > 0) {
|
||||
std.mem.copyForwards(
|
||||
u8,
|
||||
self.buffer[self.cursor - 1 .. self.len - 1],
|
||||
self.buffer[self.cursor..self.len],
|
||||
);
|
||||
}
|
||||
|
||||
self.cursor -= 1;
|
||||
self.len -= 1;
|
||||
}
|
||||
|
||||
/// Delete character at cursor (delete key)
|
||||
pub fn deleteForward(self: *TextAreaState) void {
|
||||
if (self.selection_start != null) {
|
||||
self.deleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.cursor >= self.len) return;
|
||||
|
||||
// Move text after cursor back
|
||||
const after_cursor = self.len - self.cursor - 1;
|
||||
if (after_cursor > 0) {
|
||||
std.mem.copyForwards(
|
||||
u8,
|
||||
self.buffer[self.cursor .. self.len - 1],
|
||||
self.buffer[self.cursor + 1 .. self.len],
|
||||
);
|
||||
}
|
||||
|
||||
self.len -= 1;
|
||||
}
|
||||
|
||||
/// Delete selected text
|
||||
fn deleteSelection(self: *TextAreaState) void {
|
||||
const start = self.selection_start orelse return;
|
||||
const sel_start = @min(start, self.cursor);
|
||||
const sel_end = @max(start, self.cursor);
|
||||
const sel_len = sel_end - sel_start;
|
||||
|
||||
if (sel_len == 0) {
|
||||
self.selection_start = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move text after selection
|
||||
const after_sel = self.len - sel_end;
|
||||
if (after_sel > 0) {
|
||||
std.mem.copyForwards(
|
||||
u8,
|
||||
self.buffer[sel_start .. sel_start + after_sel],
|
||||
self.buffer[sel_end..self.len],
|
||||
);
|
||||
}
|
||||
|
||||
self.len -= sel_len;
|
||||
self.cursor = sel_start;
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
/// Get cursor line and column
|
||||
pub fn getCursorPosition(self: TextAreaState) struct { line: usize, col: usize } {
|
||||
var line: usize = 0;
|
||||
var col: usize = 0;
|
||||
var i: usize = 0;
|
||||
|
||||
while (i < self.cursor and i < self.len) : (i += 1) {
|
||||
if (self.buffer[i] == '\n') {
|
||||
line += 1;
|
||||
col = 0;
|
||||
} else {
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .line = line, .col = col };
|
||||
}
|
||||
|
||||
/// Get byte offset for line start
|
||||
fn getLineStart(self: TextAreaState, line: usize) usize {
|
||||
if (line == 0) return 0;
|
||||
|
||||
var current_line: usize = 0;
|
||||
var i: usize = 0;
|
||||
|
||||
while (i < self.len) : (i += 1) {
|
||||
if (self.buffer[i] == '\n') {
|
||||
current_line += 1;
|
||||
if (current_line == line) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self.len;
|
||||
}
|
||||
|
||||
/// Get byte offset for line end (before newline)
|
||||
fn getLineEnd(self: TextAreaState, line: usize) usize {
|
||||
const line_start = self.getLineStart(line);
|
||||
var i = line_start;
|
||||
|
||||
while (i < self.len) : (i += 1) {
|
||||
if (self.buffer[i] == '\n') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return self.len;
|
||||
}
|
||||
|
||||
/// Count total lines
|
||||
pub fn lineCount(self: TextAreaState) usize {
|
||||
var count: usize = 1;
|
||||
for (self.buffer[0..self.len]) |c| {
|
||||
if (c == '\n') count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Move cursor left
|
||||
pub fn cursorLeft(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
if (self.cursor > 0) {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right
|
||||
pub fn cursorRight(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
if (self.cursor < self.len) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor up one line
|
||||
pub fn cursorUp(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
if (pos.line == 0) {
|
||||
// Already on first line, go to start
|
||||
self.cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to previous line, same column if possible
|
||||
const prev_line_start = self.getLineStart(pos.line - 1);
|
||||
const prev_line_end = self.getLineEnd(pos.line - 1);
|
||||
const prev_line_len = prev_line_end - prev_line_start;
|
||||
|
||||
self.cursor = prev_line_start + @min(pos.col, prev_line_len);
|
||||
}
|
||||
|
||||
/// Move cursor down one line
|
||||
pub fn cursorDown(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
const total_lines = self.lineCount();
|
||||
|
||||
if (pos.line >= total_lines - 1) {
|
||||
// Already on last line, go to end
|
||||
self.cursor = self.len;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to next line, same column if possible
|
||||
const next_line_start = self.getLineStart(pos.line + 1);
|
||||
const next_line_end = self.getLineEnd(pos.line + 1);
|
||||
const next_line_len = next_line_end - next_line_start;
|
||||
|
||||
self.cursor = next_line_start + @min(pos.col, next_line_len);
|
||||
}
|
||||
|
||||
/// Move cursor to start of line
|
||||
pub fn cursorHome(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
self.cursor = self.getLineStart(pos.line);
|
||||
}
|
||||
|
||||
/// Move cursor to end of line
|
||||
pub fn cursorEnd(self: *TextAreaState, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
self.cursor = self.getLineEnd(pos.line);
|
||||
}
|
||||
|
||||
/// Move cursor up one page
|
||||
pub fn pageUp(self: *TextAreaState, visible_lines: usize, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
const lines_to_move = @min(pos.line, visible_lines);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < lines_to_move) : (i += 1) {
|
||||
const save_sel = self.selection_start;
|
||||
self.cursorUp(false);
|
||||
self.selection_start = save_sel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor down one page
|
||||
pub fn pageDown(self: *TextAreaState, visible_lines: usize, shift: bool) void {
|
||||
if (shift and self.selection_start == null) {
|
||||
self.selection_start = self.cursor;
|
||||
} else if (!shift) {
|
||||
self.selection_start = null;
|
||||
}
|
||||
|
||||
const pos = self.getCursorPosition();
|
||||
const total_lines = self.lineCount();
|
||||
const lines_to_move = @min(total_lines - 1 - pos.line, visible_lines);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < lines_to_move) : (i += 1) {
|
||||
const save_sel = self.selection_start;
|
||||
self.cursorDown(false);
|
||||
self.selection_start = save_sel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select all text
|
||||
pub fn selectAll(self: *TextAreaState) void {
|
||||
self.selection_start = 0;
|
||||
self.cursor = self.len;
|
||||
}
|
||||
|
||||
/// Ensure cursor is visible by adjusting scroll
|
||||
pub fn ensureCursorVisible(self: *TextAreaState, visible_lines: usize, visible_cols: usize) void {
|
||||
const pos = self.getCursorPosition();
|
||||
|
||||
// Vertical scroll
|
||||
if (pos.line < self.scroll_y) {
|
||||
self.scroll_y = pos.line;
|
||||
} else if (pos.line >= self.scroll_y + visible_lines) {
|
||||
self.scroll_y = pos.line - visible_lines + 1;
|
||||
}
|
||||
|
||||
// Horizontal scroll
|
||||
if (pos.col < self.scroll_x) {
|
||||
self.scroll_x = pos.col;
|
||||
} else if (pos.col >= self.scroll_x + visible_cols) {
|
||||
self.scroll_x = pos.col - visible_cols + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Text area configuration
|
||||
pub const TextAreaConfig = struct {
|
||||
/// Placeholder text when empty
|
||||
placeholder: []const u8 = "",
|
||||
/// Read-only mode
|
||||
readonly: bool = false,
|
||||
/// Show line numbers
|
||||
line_numbers: bool = false,
|
||||
/// Word wrap
|
||||
word_wrap: bool = false,
|
||||
/// Tab size in spaces
|
||||
tab_size: u8 = 4,
|
||||
/// Padding inside the text area
|
||||
padding: u32 = 4,
|
||||
};
|
||||
|
||||
/// Text area colors
|
||||
pub const TextAreaColors = struct {
|
||||
background: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
||||
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||
placeholder: Style.Color = Style.Color.rgba(128, 128, 128, 255),
|
||||
cursor: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
||||
selection: Style.Color = Style.Color.rgba(50, 100, 150, 180),
|
||||
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||
border_focused: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
||||
line_numbers_bg: Style.Color = Style.Color.rgba(40, 40, 40, 255),
|
||||
line_numbers_fg: Style.Color = Style.Color.rgba(128, 128, 128, 255),
|
||||
|
||||
pub fn fromTheme(theme: Style.Theme) TextAreaColors {
|
||||
return .{
|
||||
.background = theme.input_bg,
|
||||
.text = theme.input_fg,
|
||||
.placeholder = theme.secondary,
|
||||
.cursor = theme.foreground,
|
||||
.selection = theme.selection_bg,
|
||||
.border = theme.input_border,
|
||||
.border_focused = theme.primary,
|
||||
.line_numbers_bg = theme.background.darken(10),
|
||||
.line_numbers_fg = theme.secondary,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of text area widget
|
||||
pub const TextAreaResult = struct {
|
||||
/// Text was changed this frame
|
||||
changed: bool,
|
||||
/// Widget was clicked (for focus management)
|
||||
clicked: bool,
|
||||
/// Current cursor position
|
||||
cursor_line: usize,
|
||||
cursor_col: usize,
|
||||
};
|
||||
|
||||
/// Draw a text area and return interaction result
|
||||
pub fn textArea(ctx: *Context, state: *TextAreaState) TextAreaResult {
|
||||
return textAreaEx(ctx, state, .{}, .{});
|
||||
}
|
||||
|
||||
/// Draw a text area with custom configuration
|
||||
pub fn textAreaEx(
|
||||
ctx: *Context,
|
||||
state: *TextAreaState,
|
||||
config: TextAreaConfig,
|
||||
colors: TextAreaColors,
|
||||
) TextAreaResult {
|
||||
const bounds = ctx.layout.nextRect();
|
||||
return textAreaRect(ctx, bounds, state, config, colors);
|
||||
}
|
||||
|
||||
/// Draw a text area in a specific rectangle
|
||||
pub fn textAreaRect(
|
||||
ctx: *Context,
|
||||
bounds: Layout.Rect,
|
||||
state: *TextAreaState,
|
||||
config: TextAreaConfig,
|
||||
colors: TextAreaColors,
|
||||
) TextAreaResult {
|
||||
var result = TextAreaResult{
|
||||
.changed = false,
|
||||
.clicked = false,
|
||||
.cursor_line = 0,
|
||||
.cursor_col = 0,
|
||||
};
|
||||
|
||||
if (bounds.isEmpty()) return result;
|
||||
|
||||
// Check mouse interaction
|
||||
const mouse = ctx.input.mousePos();
|
||||
const hovered = bounds.contains(mouse.x, mouse.y);
|
||||
const clicked = hovered and ctx.input.mousePressed(.left);
|
||||
|
||||
if (clicked) {
|
||||
state.focused = true;
|
||||
result.clicked = true;
|
||||
}
|
||||
|
||||
// Get colors
|
||||
const bg_color = if (state.focused) colors.background.lighten(5) else colors.background;
|
||||
const border_color = if (state.focused) colors.border_focused else colors.border;
|
||||
|
||||
// Draw background
|
||||
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
|
||||
|
||||
// Draw border
|
||||
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
|
||||
|
||||
// Calculate dimensions
|
||||
const char_width: u32 = 8;
|
||||
const char_height: u32 = 8;
|
||||
const line_height: u32 = char_height + 2;
|
||||
|
||||
// Line numbers width
|
||||
const line_num_width: u32 = if (config.line_numbers)
|
||||
@as(u32, @intCast(countDigits(state.lineCount()))) * char_width + 8
|
||||
else
|
||||
0;
|
||||
|
||||
// Inner area for text
|
||||
var text_area = bounds.shrink(config.padding);
|
||||
if (text_area.isEmpty()) return result;
|
||||
|
||||
// Draw line numbers gutter
|
||||
if (config.line_numbers and line_num_width > 0) {
|
||||
ctx.pushCommand(Command.rect(
|
||||
text_area.x,
|
||||
text_area.y,
|
||||
line_num_width,
|
||||
text_area.h,
|
||||
colors.line_numbers_bg,
|
||||
));
|
||||
// Adjust text area to exclude gutter
|
||||
text_area = Layout.Rect.init(
|
||||
text_area.x + @as(i32, @intCast(line_num_width)),
|
||||
text_area.y,
|
||||
text_area.w -| line_num_width,
|
||||
text_area.h,
|
||||
);
|
||||
}
|
||||
|
||||
if (text_area.isEmpty()) return result;
|
||||
|
||||
// Calculate visible area
|
||||
const visible_lines = text_area.h / line_height;
|
||||
const visible_cols = text_area.w / char_width;
|
||||
|
||||
// Handle keyboard input if focused
|
||||
if (state.focused and !config.readonly) {
|
||||
const text_in = ctx.input.getTextInput();
|
||||
if (text_in.len > 0) {
|
||||
// Check for tab
|
||||
for (text_in) |c| {
|
||||
if (c == '\t') {
|
||||
// Insert spaces for tab
|
||||
var spaces: [8]u8 = undefined;
|
||||
const count = @min(config.tab_size, 8);
|
||||
@memset(spaces[0..count], ' ');
|
||||
state.insert(spaces[0..count]);
|
||||
} else {
|
||||
state.insert(&[_]u8{c});
|
||||
}
|
||||
}
|
||||
result.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cursor is visible
|
||||
state.ensureCursorVisible(visible_lines, visible_cols);
|
||||
|
||||
// Get cursor position
|
||||
const cursor_pos = state.getCursorPosition();
|
||||
result.cursor_line = cursor_pos.line;
|
||||
result.cursor_col = cursor_pos.col;
|
||||
|
||||
// Draw text line by line
|
||||
const txt = state.text();
|
||||
var line_num: usize = 0;
|
||||
var line_start: usize = 0;
|
||||
|
||||
for (txt, 0..) |c, i| {
|
||||
if (c == '\n') {
|
||||
if (line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) {
|
||||
const draw_line = line_num - state.scroll_y;
|
||||
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
|
||||
|
||||
// Draw line number
|
||||
if (config.line_numbers) {
|
||||
drawLineNumber(
|
||||
ctx,
|
||||
bounds.x + @as(i32, @intCast(config.padding)),
|
||||
y,
|
||||
line_num + 1,
|
||||
colors.line_numbers_fg,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw line text
|
||||
const line_text = txt[line_start..i];
|
||||
drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text);
|
||||
|
||||
// Draw selection on this line
|
||||
if (state.selection_start != null) {
|
||||
drawLineSelection(
|
||||
ctx,
|
||||
text_area.x,
|
||||
y,
|
||||
line_start,
|
||||
i,
|
||||
state.cursor,
|
||||
state.selection_start.?,
|
||||
state.scroll_x,
|
||||
visible_cols,
|
||||
char_width,
|
||||
line_height,
|
||||
colors.selection,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw cursor if on this line
|
||||
if (state.focused and cursor_pos.line == line_num) {
|
||||
const cursor_x_pos = cursor_pos.col -| state.scroll_x;
|
||||
if (cursor_x_pos < visible_cols) {
|
||||
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
|
||||
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line_num += 1;
|
||||
line_start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last line (no trailing newline)
|
||||
if (line_start <= txt.len and line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) {
|
||||
const draw_line = line_num - state.scroll_y;
|
||||
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
|
||||
|
||||
// Draw line number
|
||||
if (config.line_numbers) {
|
||||
drawLineNumber(
|
||||
ctx,
|
||||
bounds.x + @as(i32, @intCast(config.padding)),
|
||||
y,
|
||||
line_num + 1,
|
||||
colors.line_numbers_fg,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw line text
|
||||
const line_text = if (line_start < txt.len) txt[line_start..] else "";
|
||||
drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text);
|
||||
|
||||
// Draw selection on this line
|
||||
if (state.selection_start != null) {
|
||||
drawLineSelection(
|
||||
ctx,
|
||||
text_area.x,
|
||||
y,
|
||||
line_start,
|
||||
txt.len,
|
||||
state.cursor,
|
||||
state.selection_start.?,
|
||||
state.scroll_x,
|
||||
visible_cols,
|
||||
char_width,
|
||||
line_height,
|
||||
colors.selection,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw cursor if on this line
|
||||
if (state.focused and cursor_pos.line == line_num) {
|
||||
const cursor_x_pos = cursor_pos.col -| state.scroll_x;
|
||||
if (cursor_x_pos < visible_cols) {
|
||||
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
|
||||
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw placeholder if empty
|
||||
if (state.len == 0 and config.placeholder.len > 0) {
|
||||
const y = text_area.y;
|
||||
ctx.pushCommand(Command.text(text_area.x, y, config.placeholder, colors.placeholder));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Draw a line number
|
||||
fn drawLineNumber(ctx: *Context, x: i32, y: i32, num: usize, color: Style.Color) void {
|
||||
var buf: [16]u8 = undefined;
|
||||
const written = std.fmt.bufPrint(&buf, "{d}", .{num}) catch return;
|
||||
ctx.pushCommand(Command.text(x, y, written, color));
|
||||
}
|
||||
|
||||
/// Draw line text with horizontal scroll
|
||||
fn drawLineText(
|
||||
ctx: *Context,
|
||||
x: i32,
|
||||
y: i32,
|
||||
line: []const u8,
|
||||
scroll_x: usize,
|
||||
visible_cols: usize,
|
||||
color: Style.Color,
|
||||
) void {
|
||||
if (line.len == 0) return;
|
||||
|
||||
const start = @min(scroll_x, line.len);
|
||||
const end = @min(scroll_x + visible_cols, line.len);
|
||||
|
||||
if (start >= end) return;
|
||||
|
||||
ctx.pushCommand(Command.text(x, y, line[start..end], color));
|
||||
}
|
||||
|
||||
/// Draw selection highlight for a line
|
||||
fn drawLineSelection(
|
||||
ctx: *Context,
|
||||
x: i32,
|
||||
y: i32,
|
||||
line_start: usize,
|
||||
line_end: usize,
|
||||
cursor: usize,
|
||||
sel_start: usize,
|
||||
scroll_x: usize,
|
||||
visible_cols: usize,
|
||||
char_width: u32,
|
||||
line_height: u32,
|
||||
color: Style.Color,
|
||||
) void {
|
||||
const sel_min = @min(cursor, sel_start);
|
||||
const sel_max = @max(cursor, sel_start);
|
||||
|
||||
// Check if selection overlaps this line
|
||||
if (sel_max < line_start or sel_min > line_end) return;
|
||||
|
||||
// Calculate selection bounds within line
|
||||
const sel_line_start = if (sel_min > line_start) sel_min - line_start else 0;
|
||||
const sel_line_end = @min(sel_max, line_end) - line_start;
|
||||
|
||||
if (sel_line_start >= sel_line_end) return;
|
||||
|
||||
// Apply horizontal scroll
|
||||
const vis_start = if (sel_line_start > scroll_x) sel_line_start - scroll_x else 0;
|
||||
const vis_end = if (sel_line_end > scroll_x) @min(sel_line_end - scroll_x, visible_cols) else 0;
|
||||
|
||||
if (vis_start >= vis_end) return;
|
||||
|
||||
const sel_x = x + @as(i32, @intCast(vis_start * char_width));
|
||||
const sel_w = @as(u32, @intCast(vis_end - vis_start)) * char_width;
|
||||
|
||||
ctx.pushCommand(Command.rect(sel_x, y, sel_w, line_height, color));
|
||||
}
|
||||
|
||||
/// Count digits in a number
|
||||
fn countDigits(n: usize) usize {
|
||||
if (n == 0) return 1;
|
||||
var count: usize = 0;
|
||||
var num = n;
|
||||
while (num > 0) : (num /= 10) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "TextAreaState insert" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Hello");
|
||||
try std.testing.expectEqualStrings("Hello", state.text());
|
||||
try std.testing.expectEqual(@as(usize, 5), state.cursor);
|
||||
|
||||
state.insertNewline();
|
||||
state.insert("World");
|
||||
try std.testing.expectEqualStrings("Hello\nWorld", state.text());
|
||||
}
|
||||
|
||||
test "TextAreaState line count" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Line 1");
|
||||
try std.testing.expectEqual(@as(usize, 1), state.lineCount());
|
||||
|
||||
state.insertNewline();
|
||||
state.insert("Line 2");
|
||||
try std.testing.expectEqual(@as(usize, 2), state.lineCount());
|
||||
|
||||
state.insertNewline();
|
||||
state.insertNewline();
|
||||
state.insert("Line 4");
|
||||
try std.testing.expectEqual(@as(usize, 4), state.lineCount());
|
||||
}
|
||||
|
||||
test "TextAreaState cursor position" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Hello\nWorld\nTest");
|
||||
|
||||
// Cursor at end
|
||||
const pos = state.getCursorPosition();
|
||||
try std.testing.expectEqual(@as(usize, 2), pos.line);
|
||||
try std.testing.expectEqual(@as(usize, 4), pos.col);
|
||||
}
|
||||
|
||||
test "TextAreaState cursor up/down" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Line 1\nLine 2\nLine 3");
|
||||
|
||||
// Move up
|
||||
state.cursorUp(false);
|
||||
var pos = state.getCursorPosition();
|
||||
try std.testing.expectEqual(@as(usize, 1), pos.line);
|
||||
|
||||
state.cursorUp(false);
|
||||
pos = state.getCursorPosition();
|
||||
try std.testing.expectEqual(@as(usize, 0), pos.line);
|
||||
|
||||
// Move down
|
||||
state.cursorDown(false);
|
||||
pos = state.getCursorPosition();
|
||||
try std.testing.expectEqual(@as(usize, 1), pos.line);
|
||||
}
|
||||
|
||||
test "TextAreaState home/end" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Hello World");
|
||||
state.cursorHome(false);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), state.cursor);
|
||||
|
||||
state.cursorEnd(false);
|
||||
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||
}
|
||||
|
||||
test "TextAreaState selection" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
state.insert("Hello World");
|
||||
state.selectAll();
|
||||
|
||||
try std.testing.expectEqual(@as(?usize, 0), state.selection_start);
|
||||
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||
|
||||
state.insert("X");
|
||||
try std.testing.expectEqualStrings("X", state.text());
|
||||
}
|
||||
|
||||
test "textArea generates commands" {
|
||||
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||
defer ctx.deinit();
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var state = TextAreaState.init(&buf);
|
||||
|
||||
ctx.beginFrame();
|
||||
ctx.layout.row_height = 100;
|
||||
|
||||
_ = textArea(&ctx, &state);
|
||||
|
||||
// Should generate: rect (bg) + rect_outline (border)
|
||||
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||
|
||||
ctx.endFrame();
|
||||
}
|
||||
|
||||
test "countDigits" {
|
||||
try std.testing.expectEqual(@as(usize, 1), countDigits(0));
|
||||
try std.testing.expectEqual(@as(usize, 1), countDigits(5));
|
||||
try std.testing.expectEqual(@as(usize, 2), countDigits(10));
|
||||
try std.testing.expectEqual(@as(usize, 3), countDigits(100));
|
||||
try std.testing.expectEqual(@as(usize, 4), countDigits(1234));
|
||||
}
|
||||
Loading…
Reference in a new issue