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