zcatgui/src/widgets/numberentry.zig
reugenio e0cbbf6413 feat: Focus ring AA para todos los widgets focusables
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>
2025-12-17 09:24:50 +01:00

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();
}