diff --git a/CHANGELOG.md b/CHANGELOG.md index e21376b..ea2dbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ | 2025-12-17 | v0.21.0 | AdvancedTable: +990 LOC (multi-select, search, validation) | | 2025-12-17 | v0.21.1 | Fix: AdvancedTable teclado - result.selected_row/col en handleKeyboard | | 2025-12-19 | v0.21.2 | AdvancedTable: selected_row_unfocus, color selección según focus | +| 2025-12-19 | v0.22.0 | ⭐ AutoComplete: focus system integration, getTextInput(), first_frame guard | +| 2025-12-19 | v0.22.1 | ⭐ Text Metrics: ctx.measureText/measureTextToCursor para fuentes TTF de ancho variable | +| 2025-12-19 | v0.22.2 | Cursor blink rate: 500ms→300ms (más responsive durante edición) | --- @@ -55,3 +58,9 @@ Sistema de rendering dual (simple/fancy), esquinas redondeadas, sombras, transic ### v0.20.0-v0.21.1 - AdvancedTable (2025-12-17) Widget de tabla avanzada con schema, CRUD, sorting, lookup, multi-select, search, validation. → Detalle: `docs/ADVANCED_TABLE_MERGE_PLAN.md` + +### v0.22.0-v0.22.2 - AutoComplete + Text Metrics (2025-12-19) +- **AutoComplete**: Integración completa con sistema de focus (registerFocusable, requestFocus, hasFocus) +- **Text Metrics**: Nuevo sistema ctx.measureText() para posicionamiento correcto del cursor con fuentes TTF +- **Cursor**: Velocidad de parpadeo aumentada (500ms→300ms) para mejor feedback durante edición +→ Archivos: `context.zig`, `text_input.zig`, `autocomplete.zig` diff --git a/src/core/context.zig b/src/core/context.zig index 610e052..f8de154 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -99,12 +99,20 @@ pub const Context = struct { /// Used for idle detection (e.g., cursor stops blinking after inactivity) last_input_time_ms: u64 = 0, + /// Optional text measurement function (set by application with TTF font) + /// Returns pixel width of text. If null, falls back to char_width * len. + text_measure_fn: ?*const fn ([]const u8) u32 = null, + + /// Default character width for fallback measurement (bitmap fonts) + char_width: u32 = 8, + /// Idle timeout for cursor blinking (ms). After this time without input, /// cursor becomes solid and no animation frames are needed. pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 5000; /// Cursor blink period (ms). Cursor toggles visibility at this rate. - pub const CURSOR_BLINK_PERIOD_MS: u64 = 500; + /// 300ms = ~3.3 blinks/sec (faster for better editing feedback) + pub const CURSOR_BLINK_PERIOD_MS: u64 = 300; const Self = @This(); @@ -285,6 +293,38 @@ pub const Context = struct { self.current_time_ms = time_ms; } + // ========================================================================= + // Text Metrics + // ========================================================================= + + /// Measure text width in pixels. + /// Uses TTF font metrics if text_measure_fn is set, otherwise falls back to char_width * len. + pub fn measureText(self: *const Self, text: []const u8) u32 { + if (self.text_measure_fn) |measure_fn| { + return measure_fn(text); + } + // Fallback: fixed-width calculation + return @as(u32, @intCast(text.len)) * self.char_width; + } + + /// Measure text width up to cursor position (for cursor placement). + /// text: the full text + /// cursor_pos: character position (byte index for ASCII, needs UTF-8 handling for unicode) + pub fn measureTextToCursor(self: *const Self, text: []const u8, cursor_pos: usize) u32 { + const end = @min(cursor_pos, text.len); + return self.measureText(text[0..end]); + } + + /// Set the text measurement function (typically from TTF font) + pub fn setTextMeasureFn(self: *Self, measure_fn: ?*const fn ([]const u8) u32) void { + self.text_measure_fn = measure_fn; + } + + /// Set character width for fallback measurement (bitmap fonts) + pub fn setCharWidth(self: *Self, width: u32) void { + self.char_width = width; + } + /// Get current time in milliseconds pub fn getTime(self: Self) u64 { return self.current_time_ms; diff --git a/src/widgets/autocomplete.zig b/src/widgets/autocomplete.zig index 54e77a4..b0321da 100644 --- a/src/widgets/autocomplete.zig +++ b/src/widgets/autocomplete.zig @@ -36,6 +36,10 @@ pub const AutoCompleteState = struct { /// Last filter text (for change detection) last_filter: [256]u8 = [_]u8{0} ** 256, last_filter_len: usize = 0, + /// Track previous focus state for detecting focus changes + was_focused: bool = false, + /// First frame flag to avoid false focus detection + first_frame: bool = true, const Self = @This(); @@ -84,6 +88,29 @@ pub const AutoCompleteState = struct { self.cursor += 1; } + /// Insert text at cursor (for pasting or text input) + pub fn insert(self: *Self, new_text: []const u8) void { + 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; + } + /// Delete character before cursor (backspace) pub fn backspace(self: *Self) void { if (self.cursor == 0) return; @@ -255,19 +282,46 @@ pub fn autocompleteRect( if (bounds.isEmpty()) return result; + // Generate unique ID for this widget based on buffer memory address + const widget_id: u64 = @intFromPtr(&state.buffer); + + // Register as focusable in the active focus group (for Tab navigation) + ctx.registerFocusable(widget_id); + const mouse = ctx.input.mousePos(); const input_hovered = bounds.contains(mouse.x, mouse.y); const input_clicked = input_hovered and ctx.input.mousePressed(.left); - // Determine if we should be focused (simple focus tracking) - var is_focused = state.open; + // Handle click to request focus if (input_clicked and !config.disabled) { - is_focused = true; + ctx.requestFocus(widget_id); if (config.show_on_focus) { state.openDropdown(); } } + // Check if this widget has focus using the Context focus system + const is_focused = ctx.hasFocus(widget_id); + + // Capture first_frame state before any modifications + const is_first_frame = state.first_frame; + + // Handle focus changes: open dropdown when gaining focus, close when losing + // Skip first frame to avoid false detection (was_focused starts as false) + if (!is_first_frame) { + if (is_focused and !state.was_focused) { + // Just gained focus + if (config.show_on_focus) { + state.openDropdown(); + } + } else if (!is_focused and state.was_focused) { + // Just lost focus - close dropdown + state.closeDropdown(); + } + } + state.was_focused = is_focused; + state.first_frame = false; + // Draw input field background const border_color = if (is_focused and !config.disabled) colors.input_border_focus @@ -280,20 +334,24 @@ pub fn autocompleteRect( // Get current filter text const filter_text = state.text(); - // Check if text changed + // Check if text changed (but not on first frame - that's just initialization) const text_changed = !std.mem.eql(u8, filter_text, state.last_filter[0..state.last_filter_len]); if (text_changed) { - result.text_changed = true; // Update last filter const copy_len = @min(filter_text.len, state.last_filter.len); @memcpy(state.last_filter[0..copy_len], filter_text[0..copy_len]); state.last_filter_len = copy_len; - // Reset selection when text changes - state.highlighted = 0; - state.scroll_offset = 0; - // Open dropdown when typing - if (filter_text.len >= config.min_chars) { - state.open = true; + + // Only trigger changes after first frame (first frame is just sync) + if (!is_first_frame) { + result.text_changed = true; + // Reset selection when text changes + state.highlighted = 0; + state.scroll_offset = 0; + // Open dropdown when typing + if (filter_text.len >= config.min_chars) { + state.open = true; + } } } @@ -314,7 +372,9 @@ pub fn autocompleteRect( // Draw cursor if focused if (is_focused and !config.disabled) { - const cursor_x = inner.x + @as(i32, @intCast(state.cursor * 8)); + // Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts + const cursor_offset = ctx.measureTextToCursor(filter_text, state.cursor); + const cursor_x = inner.x + @as(i32, @intCast(cursor_offset)); ctx.pushCommand(Command.rect(cursor_x, text_y, 2, char_height, Style.Color.rgb(200, 200, 200))); } @@ -439,16 +499,16 @@ pub fn autocompleteRect( .end => { state.cursor = state.len; }, - else => { - // Handle text input - if (event.char) |c| { - if (c >= 32 and c < 127) { - state.insertChar(@intCast(c)); - } - } - }, + else => {}, } } + + // Handle typed text (after key events, like TextInput does) + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + state.insert(text_in); + result.text_changed = true; + } } // Draw dropdown if open and has items diff --git a/src/widgets/text_input.zig b/src/widgets/text_input.zig index 93f00f3..afb5573 100644 --- a/src/widgets/text_input.zig +++ b/src/widgets/text_input.zig @@ -371,8 +371,9 @@ pub fn textInputRect( // Draw cursor if focused // Hybrid behavior: blinks while active, solid after idle timeout if (has_focus and !config.readonly) { - const char_width: u32 = 8; - const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width)); + // Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts + const cursor_offset = ctx.measureTextToCursor(display_text, state.cursor); + const cursor_x = inner.x + @as(i32, @intCast(cursor_offset)); const cursor_color = theme.foreground; // Determine if cursor should be visible @@ -405,11 +406,13 @@ pub fn textInputRect( // Draw selection if any if (state.selection_start) |sel_start| { - const char_width: u32 = 8; const start = @min(sel_start, state.cursor); const end = @max(sel_start, state.cursor); - const sel_x = inner.x + @as(i32, @intCast(start * char_width)); - const sel_w: u32 = @intCast((end - start) * char_width); + // Use ctx.measureTextToCursor for accurate selection with variable-width fonts + const start_offset = ctx.measureTextToCursor(display_text, start); + const end_offset = ctx.measureTextToCursor(display_text, end); + const sel_x = inner.x + @as(i32, @intCast(start_offset)); + const sel_w: u32 = end_offset - start_offset; if (sel_w > 0) { ctx.pushCommand(Command.rect(