Widgets actualizados: - NumberEntry: esquinas redondeadas + focus ring - Radio: esquinas redondeadas para círculos + focus ring en opción - Slider: esquinas redondeadas en track/thumb + focus ring - Tabs: esquinas redondeadas en tab seleccionado + focus ring - Table: focus ring alrededor de toda la tabla - TextArea: esquinas redondeadas + focus ring Nuevos campos: - TableColors.focus_ring para consistencia Total: +135 LOC en 7 archivos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
466 lines
14 KiB
Zig
466 lines
14 KiB
Zig
//! NumberEntry Widget - Numeric input with validation
|
|
//!
|
|
//! A specialized text input for numbers with spinner buttons,
|
|
//! min/max limits, and formatting options.
|
|
|
|
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");
|
|
|
|
/// Number type
|
|
pub const NumberType = enum {
|
|
integer,
|
|
float,
|
|
currency,
|
|
percentage,
|
|
};
|
|
|
|
/// Number entry state
|
|
pub const State = struct {
|
|
/// Current numeric value
|
|
value: f64 = 0,
|
|
/// Text buffer for editing
|
|
text_buf: [64]u8 = [_]u8{0} ** 64,
|
|
/// Text length
|
|
text_len: usize = 0,
|
|
/// Currently editing text (vs value)
|
|
editing: bool = false,
|
|
/// Cursor position
|
|
cursor: usize = 0,
|
|
/// Has focus
|
|
focused: bool = false,
|
|
/// Is valid
|
|
valid: bool = true,
|
|
|
|
const Self = @This();
|
|
|
|
/// Initialize with value
|
|
pub fn init(value: f64) Self {
|
|
var state = Self{ .value = value };
|
|
state.updateText();
|
|
return state;
|
|
}
|
|
|
|
/// Set value programmatically
|
|
pub fn setValue(self: *Self, value: f64) void {
|
|
self.value = value;
|
|
self.updateText();
|
|
self.valid = true;
|
|
}
|
|
|
|
/// Get current value
|
|
pub fn getValue(self: Self) f64 {
|
|
return self.value;
|
|
}
|
|
|
|
/// Get as integer
|
|
pub fn getInt(self: Self) i64 {
|
|
return @intFromFloat(self.value);
|
|
}
|
|
|
|
/// Update text from value
|
|
fn updateText(self: *Self) void {
|
|
const result = std.fmt.bufPrint(&self.text_buf, "{d:.2}", .{self.value}) catch {
|
|
self.text_len = 0;
|
|
return;
|
|
};
|
|
self.text_len = result.len;
|
|
self.cursor = self.text_len;
|
|
}
|
|
|
|
/// Try to parse text as number
|
|
fn parseText(self: *Self) bool {
|
|
if (self.text_len == 0) {
|
|
self.value = 0;
|
|
return true;
|
|
}
|
|
|
|
const txt = self.text_buf[0..self.text_len];
|
|
|
|
// Try parsing as float
|
|
self.value = std.fmt.parseFloat(f64, txt) catch {
|
|
return false;
|
|
};
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Get text slice
|
|
pub fn text(self: Self) []const u8 {
|
|
return self.text_buf[0..self.text_len];
|
|
}
|
|
|
|
/// Insert character
|
|
pub fn insert(self: *Self, char: u8) void {
|
|
if (self.text_len >= self.text_buf.len - 1) return;
|
|
|
|
// Validate character
|
|
const valid_chars = "0123456789.-+eE";
|
|
var is_valid = false;
|
|
for (valid_chars) |c| {
|
|
if (c == char) {
|
|
is_valid = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!is_valid) return;
|
|
|
|
// Shift text after cursor
|
|
if (self.cursor < self.text_len) {
|
|
var i = self.text_len;
|
|
while (i > self.cursor) : (i -= 1) {
|
|
self.text_buf[i] = self.text_buf[i - 1];
|
|
}
|
|
}
|
|
|
|
self.text_buf[self.cursor] = char;
|
|
self.cursor += 1;
|
|
self.text_len += 1;
|
|
self.editing = true;
|
|
}
|
|
|
|
/// Delete character before cursor
|
|
pub fn deleteBack(self: *Self) void {
|
|
if (self.cursor == 0) return;
|
|
|
|
// Shift text
|
|
var i = self.cursor - 1;
|
|
while (i < self.text_len - 1) : (i += 1) {
|
|
self.text_buf[i] = self.text_buf[i + 1];
|
|
}
|
|
|
|
self.cursor -= 1;
|
|
self.text_len -= 1;
|
|
self.editing = true;
|
|
}
|
|
|
|
/// Finalize editing
|
|
pub fn finishEditing(self: *Self, config: Config) void {
|
|
self.editing = false;
|
|
self.valid = self.parseText();
|
|
|
|
if (self.valid) {
|
|
// Apply limits
|
|
if (config.min) |min| {
|
|
self.value = @max(self.value, min);
|
|
}
|
|
if (config.max) |max| {
|
|
self.value = @min(self.value, max);
|
|
}
|
|
|
|
// Update text with formatted value
|
|
self.updateText();
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Number entry configuration
|
|
pub const Config = struct {
|
|
/// Number type
|
|
number_type: NumberType = .float,
|
|
/// Minimum value
|
|
min: ?f64 = null,
|
|
/// Maximum value
|
|
max: ?f64 = null,
|
|
/// Step for spinner buttons
|
|
step: f64 = 1.0,
|
|
/// Decimal precision
|
|
precision: u8 = 2,
|
|
/// Prefix (e.g., "$", "€")
|
|
prefix: ?[]const u8 = null,
|
|
/// Suffix (e.g., "%", "kg")
|
|
suffix: ?[]const u8 = null,
|
|
/// Show spinner buttons
|
|
spinner: bool = true,
|
|
/// Allow negative numbers
|
|
allow_negative: bool = true,
|
|
/// Thousand separator
|
|
thousand_separator: bool = false,
|
|
};
|
|
|
|
/// Number entry colors
|
|
pub const Colors = struct {
|
|
background: Style.Color = Style.Color.rgba(35, 35, 35, 255),
|
|
background_focused: Style.Color = Style.Color.rgba(45, 45, 45, 255),
|
|
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
|
text_invalid: Style.Color = Style.Color.rgba(255, 100, 100, 255),
|
|
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
|
border_focused: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
|
border_invalid: Style.Color = Style.Color.rgba(200, 80, 80, 255),
|
|
cursor: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
|
spinner_bg: Style.Color = Style.Color.rgba(50, 50, 50, 255),
|
|
spinner_fg: Style.Color = Style.Color.rgba(180, 180, 180, 255),
|
|
prefix_suffix: Style.Color = Style.Color.rgba(150, 150, 150, 255),
|
|
|
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
|
return .{
|
|
.background = theme.input_bg,
|
|
.background_focused = theme.input_bg.lighten(10),
|
|
.text = theme.input_fg,
|
|
.text_invalid = theme.error_color,
|
|
.border = theme.input_border,
|
|
.border_focused = theme.primary,
|
|
.border_invalid = theme.error_color,
|
|
.cursor = theme.foreground,
|
|
.spinner_bg = theme.background.lighten(15),
|
|
.spinner_fg = theme.foreground,
|
|
.prefix_suffix = theme.secondary,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Number entry result
|
|
pub const Result = struct {
|
|
/// Value changed
|
|
changed: bool = false,
|
|
/// Current value
|
|
value: f64 = 0,
|
|
/// Is valid
|
|
valid: bool = true,
|
|
/// Enter pressed
|
|
submitted: bool = false,
|
|
};
|
|
|
|
/// Draw a number entry
|
|
pub fn numberEntry(ctx: *Context, state: *State) Result {
|
|
return numberEntryEx(ctx, state, .{}, .{});
|
|
}
|
|
|
|
/// Draw a number entry with configuration
|
|
pub fn numberEntryEx(
|
|
ctx: *Context,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
const bounds = ctx.layout.nextRect();
|
|
return numberEntryRect(ctx, bounds, state, config, colors);
|
|
}
|
|
|
|
/// Draw a number entry in specific rectangle
|
|
pub fn numberEntryRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
var result = Result{
|
|
.value = state.value,
|
|
.valid = state.valid,
|
|
};
|
|
|
|
if (bounds.isEmpty()) return result;
|
|
|
|
// Generate unique ID for this widget based on state address
|
|
const widget_id: u64 = @intFromPtr(state);
|
|
|
|
// Register as focusable in the active focus group
|
|
ctx.registerFocusable(widget_id);
|
|
|
|
const mouse = ctx.input.mousePos();
|
|
const mouse_pressed = ctx.input.mousePressed(.left);
|
|
const hovered = bounds.contains(mouse.x, mouse.y);
|
|
|
|
if (hovered and mouse_pressed) {
|
|
// Request focus through the focus system
|
|
ctx.requestFocus(widget_id);
|
|
}
|
|
|
|
// Check if this widget has focus
|
|
const has_focus = ctx.hasFocus(widget_id);
|
|
state.focused = has_focus;
|
|
|
|
// Calculate areas
|
|
const spinner_w: u32 = if (config.spinner) 20 else 0;
|
|
const prefix_w: u32 = if (config.prefix) |p| @as(u32, @intCast(p.len * 8 + 4)) else 0;
|
|
const suffix_w: u32 = if (config.suffix) |s| @as(u32, @intCast(s.len * 8 + 4)) else 0;
|
|
|
|
const input_x = bounds.x + @as(i32, @intCast(prefix_w));
|
|
_ = bounds.w -| prefix_w -| suffix_w -| (spinner_w * 2); // input_w available for future use
|
|
|
|
// Colors
|
|
const bg_color = if (has_focus) colors.background_focused else colors.background;
|
|
const border_color = if (!state.valid)
|
|
colors.border_invalid
|
|
else if (has_focus)
|
|
colors.border_focused
|
|
else
|
|
colors.border;
|
|
const text_color = if (state.valid) colors.text else colors.text_invalid;
|
|
|
|
// Draw background and border (with rounded corners in fancy mode)
|
|
const corner_radius: u8 = 3;
|
|
if (Style.isFancy() and corner_radius > 0) {
|
|
ctx.pushCommand(Command.roundedRect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, corner_radius));
|
|
ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color, corner_radius));
|
|
// Focus ring
|
|
if (has_focus) {
|
|
ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, corner_radius));
|
|
}
|
|
} else {
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
|
|
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
|
|
}
|
|
|
|
// Draw prefix
|
|
if (config.prefix) |prefix| {
|
|
const prefix_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2));
|
|
ctx.pushCommand(Command.text(bounds.x + 4, prefix_y, prefix, colors.prefix_suffix));
|
|
}
|
|
|
|
// Draw text
|
|
const text_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2));
|
|
ctx.pushCommand(Command.text(input_x + 4, text_y, state.text(), text_color));
|
|
|
|
// Draw cursor if focused
|
|
if (has_focus) {
|
|
const cursor_x = input_x + 4 + @as(i32, @intCast(state.cursor * 8));
|
|
ctx.pushCommand(Command.rect(cursor_x, bounds.y + 4, 2, bounds.h - 8, colors.cursor));
|
|
}
|
|
|
|
// Draw suffix
|
|
if (config.suffix) |suffix| {
|
|
const suffix_x = bounds.x + @as(i32, @intCast(bounds.w - suffix_w - spinner_w * 2));
|
|
const suffix_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2));
|
|
ctx.pushCommand(Command.text(suffix_x, suffix_y, suffix, colors.prefix_suffix));
|
|
}
|
|
|
|
// Draw spinner buttons
|
|
if (config.spinner) {
|
|
const btn_w = spinner_w;
|
|
const btn_h = bounds.h / 2;
|
|
|
|
// Up button
|
|
const up_x = bounds.x + @as(i32, @intCast(bounds.w - btn_w));
|
|
const up_rect = Layout.Rect.init(up_x, bounds.y, btn_w, btn_h);
|
|
ctx.pushCommand(Command.rect(up_rect.x, up_rect.y, up_rect.w, up_rect.h, colors.spinner_bg));
|
|
ctx.pushCommand(Command.text(up_x + 6, bounds.y + 2, "+", colors.spinner_fg));
|
|
|
|
if (up_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
|
state.value += config.step;
|
|
if (config.max) |max| {
|
|
state.value = @min(state.value, max);
|
|
}
|
|
state.updateText();
|
|
result.changed = true;
|
|
result.value = state.value;
|
|
}
|
|
|
|
// Down button
|
|
const down_y = bounds.y + @as(i32, @intCast(btn_h));
|
|
const down_rect = Layout.Rect.init(up_x, down_y, btn_w, btn_h);
|
|
ctx.pushCommand(Command.rect(down_rect.x, down_rect.y, down_rect.w, down_rect.h, colors.spinner_bg));
|
|
ctx.pushCommand(Command.text(up_x + 6, down_y + 2, "-", colors.spinner_fg));
|
|
|
|
if (down_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
|
state.value -= config.step;
|
|
if (config.min) |min| {
|
|
state.value = @max(state.value, min);
|
|
}
|
|
if (!config.allow_negative and state.value < 0) {
|
|
state.value = 0;
|
|
}
|
|
state.updateText();
|
|
result.changed = true;
|
|
result.value = state.value;
|
|
}
|
|
}
|
|
|
|
// Handle text input
|
|
if (has_focus) {
|
|
const text_input = ctx.input.getTextInput();
|
|
for (text_input) |char| {
|
|
state.insert(char);
|
|
}
|
|
|
|
if (text_input.len > 0) {
|
|
state.valid = state.parseText();
|
|
if (state.valid) {
|
|
result.changed = true;
|
|
result.value = state.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
result.valid = state.valid;
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "State init" {
|
|
const state = State.init(42.5);
|
|
try std.testing.expectEqual(@as(f64, 42.5), state.value);
|
|
try std.testing.expect(state.text_len > 0);
|
|
}
|
|
|
|
test "State setValue" {
|
|
var state = State.init(0);
|
|
state.setValue(123.45);
|
|
try std.testing.expectEqual(@as(f64, 123.45), state.value);
|
|
}
|
|
|
|
test "State insert and parse" {
|
|
var state = State.init(0);
|
|
state.text_len = 0;
|
|
state.cursor = 0;
|
|
|
|
state.insert('1');
|
|
state.insert('2');
|
|
state.insert('3');
|
|
|
|
try std.testing.expectEqual(@as(usize, 3), state.text_len);
|
|
try std.testing.expect(state.parseText());
|
|
try std.testing.expectEqual(@as(f64, 123), state.value);
|
|
}
|
|
|
|
test "State with decimal" {
|
|
var state = State.init(0);
|
|
state.text_len = 0;
|
|
state.cursor = 0;
|
|
|
|
state.insert('3');
|
|
state.insert('.');
|
|
state.insert('1');
|
|
state.insert('4');
|
|
|
|
try std.testing.expect(state.parseText());
|
|
try std.testing.expect(state.value > 3.13 and state.value < 3.15);
|
|
}
|
|
|
|
test "State finishEditing with limits" {
|
|
var state = State.init(0);
|
|
state.setValue(150);
|
|
|
|
const config = Config{
|
|
.min = 0,
|
|
.max = 100,
|
|
};
|
|
|
|
state.finishEditing(config);
|
|
try std.testing.expectEqual(@as(f64, 100), state.value);
|
|
}
|
|
|
|
test "numberEntry generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init(42);
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 30;
|
|
|
|
_ = numberEntry(&ctx, &state);
|
|
|
|
// Should have background + border + text
|
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
|
|
|
ctx.endFrame();
|
|
}
|