From 61f0524bd3e56c459f58ba9e9d1a2894604c8769 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 29 Dec 2025 11:27:37 +0100 Subject: [PATCH] =?UTF-8?q?refactor(autocomplete):=20Modularizar=20en=20ca?= =?UTF-8?q?rpeta=20(910=E2=86=92571=20LOC=20hub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extraer state.zig: AutoCompleteState (200 LOC) - Extraer types.zig: MatchMode, Config, Colors, Result (89 LOC) - Extraer filtering.zig: matchesFilter, prefix, contains, fuzzy (106 LOC) - autocomplete.zig: hub principal con widget y convenience functions (571 LOC) - Actualizar widgets.zig con nueva ruta import --- src/widgets/autocomplete.zig | 910 ---------------------- src/widgets/autocomplete/autocomplete.zig | 571 ++++++++++++++ src/widgets/autocomplete/filtering.zig | 106 +++ src/widgets/autocomplete/state.zig | 200 +++++ src/widgets/autocomplete/types.zig | 89 +++ src/widgets/widgets.zig | 2 +- 6 files changed, 967 insertions(+), 911 deletions(-) delete mode 100644 src/widgets/autocomplete.zig create mode 100644 src/widgets/autocomplete/autocomplete.zig create mode 100644 src/widgets/autocomplete/filtering.zig create mode 100644 src/widgets/autocomplete/state.zig create mode 100644 src/widgets/autocomplete/types.zig diff --git a/src/widgets/autocomplete.zig b/src/widgets/autocomplete.zig deleted file mode 100644 index 0aafc92..0000000 --- a/src/widgets/autocomplete.zig +++ /dev/null @@ -1,910 +0,0 @@ -//! AutoComplete/ComboBox Widget - Dropdown with text filtering -//! -//! Combines a text input with a dropdown list for: -//! - Type-ahead filtering of options -//! - Free-form text entry (optional) -//! - Used for provinces, countries, IVA types, etc. -//! -//! Similar to Simifactu's autocomplete fields. - -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"); - -// ============================================================================= -// AutoComplete State -// ============================================================================= - -/// AutoComplete state (caller-managed) -pub const AutoCompleteState = struct { - /// Internal text buffer - buffer: [256]u8 = [_]u8{0} ** 256, - /// Text length - len: usize = 0, - /// Cursor position - cursor: usize = 0, - /// Currently selected index in filtered list (-1 for none) - selected: i32 = -1, - /// Whether dropdown is open - open: bool = false, - /// Highlighted item in dropdown (for keyboard navigation) - highlighted: i32 = -1, - /// Scroll offset in dropdown - scroll_offset: usize = 0, - /// 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, - /// Texto original guardado al abrir dropdown (para restaurar con Escape) - saved_text: [256]u8 = [_]u8{0} ** 256, - saved_text_len: usize = 0, - saved_cursor: usize = 0, - - const Self = @This(); - - /// Initialize state - pub fn init() Self { - return .{}; - } - - /// Get current input text - pub fn text(self: *const Self) []const u8 { - return self.buffer[0..self.len]; - } - - /// Set text programmatically - pub fn setText(self: *Self, 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; - - // Sync filter to avoid spurious text_changed events - @memcpy(self.last_filter[0..copy_len], new_text[0..copy_len]); - self.last_filter_len = copy_len; - } - - /// Clear the input - pub fn clear(self: *Self) void { - self.len = 0; - self.cursor = 0; - self.selected = -1; - self.highlighted = -1; - self.open = false; - self.last_filter_len = 0; - } - - /// Insert a single character at cursor - pub fn insertChar(self: *Self, c: u8) void { - if (self.len >= self.buffer.len) return; - - // Move text after cursor - if (self.cursor < self.len) { - std.mem.copyBackwards( - u8, - self.buffer[self.cursor + 1 .. self.len + 1], - self.buffer[self.cursor..self.len], - ); - } - - self.buffer[self.cursor] = c; - self.len += 1; - 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; - - // Move text after cursor back - if (self.cursor < self.len) { - 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 delete(self: *Self) void { - if (self.cursor >= self.len) return; - - // Move text after cursor back - if (self.cursor + 1 < self.len) { - std.mem.copyForwards( - u8, - self.buffer[self.cursor .. self.len - 1], - self.buffer[self.cursor + 1 .. self.len], - ); - } - - self.len -= 1; - } - - /// Move cursor - pub fn moveCursor(self: *Self, delta: i32) void { - if (delta < 0) { - const abs: usize = @intCast(-delta); - if (abs > self.cursor) { - self.cursor = 0; - } else { - self.cursor -= abs; - } - } else { - const abs: usize = @intCast(delta); - self.cursor = @min(self.cursor + abs, self.len); - } - } - - /// Guarda el texto actual (para restaurar con Escape) - pub fn saveText(self: *Self) void { - @memcpy(self.saved_text[0..self.len], self.buffer[0..self.len]); - self.saved_text_len = self.len; - self.saved_cursor = self.cursor; - } - - /// Restaura el texto guardado - pub fn restoreText(self: *Self) void { - @memcpy(self.buffer[0..self.saved_text_len], self.saved_text[0..self.saved_text_len]); - self.len = self.saved_text_len; - self.cursor = self.saved_cursor; - // Sync filter - @memcpy(self.last_filter[0..self.saved_text_len], self.saved_text[0..self.saved_text_len]); - self.last_filter_len = self.saved_text_len; - } - - /// Open the dropdown (guarda texto para posible restauración con Escape) - pub fn openDropdown(self: *Self) void { - if (!self.open) { - // Solo guardar si estamos abriendo (no si ya está abierto) - self.saveText(); - } - self.open = true; - self.highlighted = if (self.selected >= 0) self.selected else 0; - } - - /// Close the dropdown - pub fn closeDropdown(self: *Self) void { - self.open = false; - self.highlighted = -1; - } - - /// Close dropdown y restaurar texto original (para Escape) - pub fn closeDropdownAndRestore(self: *Self) void { - self.restoreText(); - self.closeDropdown(); - } -}; - -// ============================================================================= -// AutoComplete Configuration -// ============================================================================= - -/// Match mode for filtering -pub const MatchMode = enum { - /// Match if option starts with filter text - prefix, - /// Match if option contains filter text anywhere - contains, - /// Match using fuzzy matching (characters in order) - fuzzy, -}; - -/// AutoComplete configuration -pub const AutoCompleteConfig = struct { - /// Placeholder text when empty - placeholder: []const u8 = "Type to search...", - /// Disabled state - disabled: bool = false, - /// Maximum visible items in dropdown - max_visible_items: usize = 8, - /// Height of each item - item_height: u32 = 24, - /// Padding - padding: u32 = 4, - /// Match mode for filtering - match_mode: MatchMode = .contains, - /// Case sensitive matching - case_sensitive: bool = false, - /// Allow free-form text (not just from options) - allow_custom: bool = false, - /// Minimum characters before showing suggestions - min_chars: usize = 0, - /// Show dropdown on focus (even if empty) - show_on_focus: bool = true, -}; - -/// AutoComplete colors -pub const AutoCompleteColors = struct { - /// Input background - input_bg: Style.Color = Style.Color.rgb(35, 35, 40), - /// Input border - input_border: Style.Color = Style.Color.rgb(80, 80, 85), - /// Input border when focused - input_border_focus: Style.Color = Style.Color.primary, - /// Dropdown background - dropdown_bg: Style.Color = Style.Color.rgb(45, 45, 50), - /// Highlighted item background - highlight_bg: Style.Color = Style.Color.rgb(60, 60, 70), - /// Selected item background - selected_bg: Style.Color = Style.Color.rgb(70, 100, 140), - /// Match highlight color (for showing matching part) - match_fg: Style.Color = Style.Color.primary, -}; - -/// AutoComplete result -pub const AutoCompleteResult = struct { - /// Selection changed this frame (from dropdown) - selection_changed: bool = false, - /// Newly selected index (valid if selection_changed) - new_index: ?usize = null, - /// Selected text (valid if selection_changed) - selected_text: ?[]const u8 = null, - /// Text was submitted (Enter pressed with valid selection or custom allowed) - submitted: bool = false, - /// Submitted text - submitted_text: ?[]const u8 = null, - /// Text changed (user typed) - text_changed: bool = false, -}; - -// ============================================================================= -// AutoComplete Functions -// ============================================================================= - -/// Draw an autocomplete widget -pub fn autocomplete( - ctx: *Context, - state: *AutoCompleteState, - options: []const []const u8, -) AutoCompleteResult { - return autocompleteEx(ctx, state, options, .{}, .{}); -} - -/// Draw an autocomplete widget with custom configuration -pub fn autocompleteEx( - ctx: *Context, - state: *AutoCompleteState, - options: []const []const u8, - config: AutoCompleteConfig, - colors: AutoCompleteColors, -) AutoCompleteResult { - const bounds = ctx.layout.nextRect(); - return autocompleteRect(ctx, bounds, state, options, config, colors); -} - -/// Draw an autocomplete widget in a specific rectangle -pub fn autocompleteRect( - ctx: *Context, - bounds: Layout.Rect, - state: *AutoCompleteState, - options: []const []const u8, - config: AutoCompleteConfig, - colors: AutoCompleteColors, -) AutoCompleteResult { - var result = AutoCompleteResult{}; - - 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); - - // Calcular área de la flecha para detección de clicks - const arrow_click_width: u32 = 20; // Zona clicable de la flecha - const arrow_area_x = bounds.x + @as(i32, @intCast(bounds.w -| arrow_click_width)); - const arrow_hovered = mouse.x >= arrow_area_x and mouse.x <= bounds.x + @as(i32, @intCast(bounds.w)) and - mouse.y >= bounds.y and mouse.y <= bounds.y + @as(i32, @intCast(bounds.h)); - const arrow_clicked = arrow_hovered and ctx.input.mousePressed(.left); - - // Handle click to request focus - if (input_clicked and !config.disabled) { - ctx.requestFocus(widget_id); - - // Click en la flecha: toggle dropdown (forzar abrir/cerrar) - if (arrow_clicked) { - if (state.open) { - state.closeDropdown(); - } else { - state.openDropdown(); - } - } else if (config.show_on_focus) { - // Click en el área de texto: abrir si show_on_focus está activo - 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 - else - colors.input_border; - - ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.input_bg)); - ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); - - // Get current filter text - const filter_text = state.text(); - - // 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) { - // 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; - - // 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; - } - } - } - - // Draw input text or placeholder - const inner = bounds.shrink(config.padding); - const char_height: u32 = 8; - const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); - - if (filter_text.len > 0) { - const text_color = if (config.disabled) - Style.Color.rgb(120, 120, 120) - else - Style.Color.rgb(220, 220, 220); - ctx.pushCommand(Command.text(inner.x, text_y, filter_text, text_color)); - } else if (config.placeholder.len > 0) { - ctx.pushCommand(Command.text(inner.x, text_y, config.placeholder, Style.Color.rgb(100, 100, 100))); - } - - // Draw cursor if focused (same style as TextInput) - if (is_focused and !config.disabled) { - // Cursor blink logic (identical to TextInput) - const cursor_visible = blk: { - if (ctx.current_time_ms == 0) break :blk true; - const idle_time = ctx.current_time_ms -| ctx.last_input_time_ms; - if (idle_time >= Context.CURSOR_IDLE_TIMEOUT_MS) { - // Idle: cursor always visible (solid, no blink) - break :blk true; - } else { - // Active: cursor blinks - break :blk (ctx.current_time_ms / Context.CURSOR_BLINK_PERIOD_MS) % 2 == 0; - } - }; - - if (cursor_visible) { - // 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)); - // Full height cursor (inner.h), same as TextInput - ctx.pushCommand(Command.rect(cursor_x, inner.y, 2, inner.h, Style.Color.rgb(200, 200, 200))); - } - } - - // Draw dropdown arrow - const arrow_size: u32 = 8; - const arrow_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.padding + arrow_size + 2)); - const arrow_y = bounds.y + @as(i32, @intCast((bounds.h -| arrow_size) / 2)); - const arrow_color = if (config.disabled) Style.Color.rgb(80, 80, 80) else Style.Color.rgb(160, 160, 160); - - ctx.pushCommand(Command.line( - arrow_x, - arrow_y, - arrow_x + @as(i32, @intCast(arrow_size / 2)), - arrow_y + @as(i32, @intCast(arrow_size / 2)), - arrow_color, - )); - ctx.pushCommand(Command.line( - arrow_x + @as(i32, @intCast(arrow_size / 2)), - arrow_y + @as(i32, @intCast(arrow_size / 2)), - arrow_x + @as(i32, @intCast(arrow_size)), - arrow_y, - arrow_color, - )); - - // Filter options - var filtered_indices: [256]usize = undefined; - var filtered_count: usize = 0; - - if (options.len == 0) { - state.closeDropdown(); - } else { - for (options, 0..) |opt, i| { - if (filtered_count >= filtered_indices.len) break; - if (matchesFilter(opt, filter_text, config.match_mode, config.case_sensitive)) { - filtered_indices[filtered_count] = i; - filtered_count += 1; - } - } - } - - // Handle keyboard input when focused - if (is_focused and !config.disabled) { - // Handle text input - for (ctx.input.getKeyEvents()) |event| { - if (!event.pressed) continue; - - switch (event.key) { - .escape => { - // Escape: Cierra dropdown y RESTAURA texto original - if (state.open) { - state.closeDropdownAndRestore(); - } - }, - .enter => { - if (state.open and state.highlighted >= 0 and state.highlighted < @as(i32, @intCast(filtered_count))) { - const idx = filtered_indices[@intCast(state.highlighted)]; - state.selected = @intCast(idx); - state.setText(options[idx]); - state.closeDropdown(); - result.selection_changed = true; - result.new_index = idx; - result.selected_text = options[idx]; - result.submitted = true; - result.submitted_text = options[idx]; - } else if (config.allow_custom and filter_text.len > 0) { - result.submitted = true; - result.submitted_text = filter_text; - state.closeDropdown(); - } - }, - .up => { - // Ctrl+Up: Cierra dropdown (sin restaurar - confirma el estado actual) - if (ctx.input.modifiers.ctrl) { - if (state.open) { - state.closeDropdown(); - } - } else if (state.open) { - // Navegar hacia arriba en la lista - if (state.highlighted > 0) { - state.highlighted -= 1; - // Scroll if needed - if (state.highlighted < @as(i32, @intCast(state.scroll_offset))) { - state.scroll_offset = @intCast(state.highlighted); - } - } - } else { - // Abrir dropdown si está cerrado - state.openDropdown(); - } - }, - .down => { - // Ctrl+Down: Forzar apertura dropdown (SIN limpiar texto) - if (ctx.input.modifiers.ctrl) { - state.highlighted = 0; - state.scroll_offset = 0; - state.openDropdown(); - } else if (state.open) { - // Navegar hacia abajo en la lista - if (state.highlighted < @as(i32, @intCast(filtered_count)) - 1) { - state.highlighted += 1; - // Scroll if needed - const max_visible: i32 = @intCast(config.max_visible_items); - if (state.highlighted >= @as(i32, @intCast(state.scroll_offset)) + max_visible) { - state.scroll_offset = @intCast(state.highlighted - max_visible + 1); - } - } - } else { - // Abrir dropdown si está cerrado - state.openDropdown(); - } - }, - .tab => { - // Accept current highlight on Tab - if (state.open and state.highlighted >= 0 and state.highlighted < @as(i32, @intCast(filtered_count))) { - const idx = filtered_indices[@intCast(state.highlighted)]; - state.selected = @intCast(idx); - state.setText(options[idx]); - state.closeDropdown(); - result.selection_changed = true; - result.new_index = idx; - result.selected_text = options[idx]; - } - }, - .backspace => { - state.backspace(); - // Abrir dropdown después de borrar (el usuario está editando) - if (state.len >= config.min_chars) { - state.open = true; - } - }, - .delete => { - state.delete(); - // Abrir dropdown después de borrar - if (state.len >= config.min_chars) { - state.open = true; - } - }, - .left => { - if (!state.open) { - state.moveCursor(-1); - } - }, - .right => { - if (!state.open) { - state.moveCursor(1); - } - }, - .home => { - state.cursor = 0; - }, - .end => { - state.cursor = state.len; - }, - 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; - // IMPORTANTE: Abrir dropdown inmediatamente después de insertar texto - // (no esperar al siguiente frame para detectar el cambio) - if (state.len >= config.min_chars) { - state.open = true; - } - } - } - - // Draw dropdown if open and has items - // OVERLAY: El dropdown se dibuja en la capa overlay para aparecer ENCIMA de otros widgets - if (state.open and filtered_count > 0) { - const visible_items = @min(filtered_count, config.max_visible_items); - const dropdown_h = visible_items * config.item_height; - const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); - - // Dropdown background (overlay) - ctx.pushOverlayCommand(Command.rect( - bounds.x, - dropdown_y, - bounds.w, - @intCast(dropdown_h), - colors.dropdown_bg, - )); - - ctx.pushOverlayCommand(Command.rectOutline( - bounds.x, - dropdown_y, - bounds.w, - @intCast(dropdown_h), - colors.input_border, - )); - - // Draw visible items - var item_y = dropdown_y; - const start = state.scroll_offset; - const end = @min(start + visible_items, filtered_count); - - for (start..end) |fi| { - const i = filtered_indices[fi]; - const item_bounds = Layout.Rect.init( - bounds.x, - item_y, - bounds.w, - config.item_height, - ); - - const item_hovered = item_bounds.contains(mouse.x, mouse.y); - const item_clicked = item_hovered and ctx.input.mousePressed(.left); - const is_highlighted = state.highlighted == @as(i32, @intCast(fi)); - const is_selected = state.selected == @as(i32, @intCast(i)); - - // Update highlight on hover - if (item_hovered) { - state.highlighted = @intCast(fi); - } - - // Item background - const item_bg = if (is_highlighted) - colors.highlight_bg - else if (is_selected) - colors.selected_bg - else - Style.Color.transparent; - - if (item_bg.a > 0) { - ctx.pushOverlayCommand(Command.rect( - item_bounds.x + 1, - item_bounds.y, - item_bounds.w - 2, - item_bounds.h, - item_bg, - )); - } - - // Item text (overlay) - const item_inner = item_bounds.shrink(config.padding); - const item_text_y = item_inner.y + @as(i32, @intCast((item_inner.h -| char_height) / 2)); - - ctx.pushOverlayCommand(Command.text(item_inner.x, item_text_y, options[i], Style.Color.rgb(220, 220, 220))); - - // Handle click selection - if (item_clicked) { - state.selected = @intCast(i); - state.setText(options[i]); - state.closeDropdown(); - result.selection_changed = true; - result.new_index = i; - result.selected_text = options[i]; - } - - item_y += @as(i32, @intCast(config.item_height)); - } - - // Close dropdown if clicked outside - if (ctx.input.mousePressed(.left) and !input_hovered) { - const dropdown_bounds = Layout.Rect.init( - bounds.x, - dropdown_y, - bounds.w, - @intCast(dropdown_h), - ); - if (!dropdown_bounds.contains(mouse.x, mouse.y)) { - state.closeDropdown(); - } - } - } else if (state.open and filtered_count == 0 and filter_text.len > 0) { - // Show "no matches" message (overlay) - const no_match_h: u32 = config.item_height; - const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); - - ctx.pushOverlayCommand(Command.rect( - bounds.x, - dropdown_y, - bounds.w, - no_match_h, - colors.dropdown_bg, - )); - - ctx.pushOverlayCommand(Command.rectOutline( - bounds.x, - dropdown_y, - bounds.w, - no_match_h, - colors.input_border, - )); - - const no_match_text = if (config.allow_custom) "Press Enter to use custom value" else "No matches found"; - const msg_y = dropdown_y + @as(i32, @intCast((no_match_h -| char_height) / 2)); - ctx.pushOverlayCommand(Command.text(bounds.x + @as(i32, @intCast(config.padding)), msg_y, no_match_text, Style.Color.rgb(120, 120, 120))); - - // Close if clicked outside - if (ctx.input.mousePressed(.left) and !input_hovered) { - const dropdown_bounds = Layout.Rect.init(bounds.x, dropdown_y, bounds.w, no_match_h); - if (!dropdown_bounds.contains(mouse.x, mouse.y)) { - state.closeDropdown(); - } - } - } - - return result; -} - -// ============================================================================= -// Filtering Helpers -// ============================================================================= - -/// Check if option matches filter -fn matchesFilter(option: []const u8, filter: []const u8, mode: MatchMode, case_sensitive: bool) bool { - if (filter.len == 0) return true; - - return switch (mode) { - .prefix => matchesPrefix(option, filter, case_sensitive), - .contains => matchesContains(option, filter, case_sensitive), - .fuzzy => matchesFuzzy(option, filter, case_sensitive), - }; -} - -fn matchesPrefix(option: []const u8, filter: []const u8, case_sensitive: bool) bool { - if (filter.len > option.len) return false; - - if (case_sensitive) { - return std.mem.startsWith(u8, option, filter); - } else { - for (0..filter.len) |i| { - if (std.ascii.toLower(option[i]) != std.ascii.toLower(filter[i])) { - return false; - } - } - return true; - } -} - -fn matchesContains(option: []const u8, filter: []const u8, case_sensitive: bool) bool { - if (filter.len > option.len) return false; - - if (case_sensitive) { - return std.mem.indexOf(u8, option, filter) != null; - } else { - // Case insensitive contains - const option_len = option.len; - const filter_len = filter.len; - - var i: usize = 0; - while (i + filter_len <= option_len) : (i += 1) { - var matches = true; - for (0..filter_len) |j| { - if (std.ascii.toLower(option[i + j]) != std.ascii.toLower(filter[j])) { - matches = false; - break; - } - } - if (matches) return true; - } - return false; - } -} - -fn matchesFuzzy(option: []const u8, filter: []const u8, case_sensitive: bool) bool { - // Fuzzy: each filter char must appear in order - var filter_idx: usize = 0; - - for (option) |c| { - if (filter_idx >= filter.len) break; - - const fc = filter[filter_idx]; - const matches = if (case_sensitive) - c == fc - else - std.ascii.toLower(c) == std.ascii.toLower(fc); - - if (matches) { - filter_idx += 1; - } - } - - return filter_idx >= filter.len; -} - -// ============================================================================= -// Convenience Functions -// ============================================================================= - -/// Create a province autocomplete (common use case) -pub fn provinceAutocomplete( - ctx: *Context, - state: *AutoCompleteState, - provinces: []const []const u8, -) AutoCompleteResult { - return autocompleteEx(ctx, state, provinces, .{ - .placeholder = "Select province...", - .match_mode = .contains, - .case_sensitive = false, - .min_chars = 0, - }, .{}); -} - -/// Create a country autocomplete -pub fn countryAutocomplete( - ctx: *Context, - state: *AutoCompleteState, - countries: []const []const u8, -) AutoCompleteResult { - return autocompleteEx(ctx, state, countries, .{ - .placeholder = "Select country...", - .match_mode = .prefix, - .case_sensitive = false, - .min_chars = 1, - }, .{}); -} - -// ============================================================================= -// Tests -// ============================================================================= - -test "AutoCompleteState init" { - var state = AutoCompleteState.init(); - try std.testing.expectEqual(@as(usize, 0), state.text().len); - try std.testing.expect(!state.open); - try std.testing.expectEqual(@as(i32, -1), state.selected); -} - -test "AutoCompleteState setText" { - var state = AutoCompleteState.init(); - state.setText("Madrid"); - try std.testing.expectEqualStrings("Madrid", state.text()); -} - -test "matchesFilter prefix" { - try std.testing.expect(matchesPrefix("Madrid", "Mad", false)); - try std.testing.expect(matchesPrefix("Madrid", "mad", false)); - try std.testing.expect(!matchesPrefix("Barcelona", "Mad", false)); - try std.testing.expect(!matchesPrefix("Madrid", "Mad", true) == false); // case sensitive, exact match -} - -test "matchesFilter contains" { - try std.testing.expect(matchesContains("Madrid", "dri", false)); - try std.testing.expect(matchesContains("Madrid", "DRI", false)); - try std.testing.expect(!matchesContains("Barcelona", "dri", false)); -} - -test "matchesFilter fuzzy" { - try std.testing.expect(matchesFuzzy("Madrid", "mrd", false)); // m-a-d-r-i-d contains m, r, d in order - try std.testing.expect(matchesFuzzy("Barcelona", "bcn", false)); // b-a-r-c-e-l-o-n-a contains b, c, n in order - try std.testing.expect(!matchesFuzzy("Madrid", "xyz", false)); -} - -test "autocomplete generates commands" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - var state = AutoCompleteState.init(); - const options = [_][]const u8{ "Madrid", "Barcelona", "Valencia" }; - - ctx.beginFrame(); - ctx.layout.row_height = 30; - - _ = autocomplete(&ctx, &state, &options); - - // Should generate: rect (bg) + rect_outline (border) + text (placeholder) + 2 lines (arrow) - try std.testing.expect(ctx.commands.items.len >= 4); - - ctx.endFrame(); -} diff --git a/src/widgets/autocomplete/autocomplete.zig b/src/widgets/autocomplete/autocomplete.zig new file mode 100644 index 0000000..1b28e1a --- /dev/null +++ b/src/widgets/autocomplete/autocomplete.zig @@ -0,0 +1,571 @@ +//! AutoComplete/ComboBox Widget - Dropdown with text filtering +//! +//! Combines a text input with a dropdown list for: +//! - Type-ahead filtering of options +//! - Free-form text entry (optional) +//! - Used for provinces, countries, IVA types, etc. +//! +//! Similar to Simifactu's autocomplete fields. +//! +//! ## Estructura modular +//! +//! - state.zig: AutoCompleteState +//! - types.zig: Config, Colors, Result, MatchMode +//! - filtering.zig: Funciones de matching +//! - autocomplete.zig: Hub principal (este archivo) + +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"); + +// Re-exports desde módulos +pub const state_mod = @import("state.zig"); +pub const AutoCompleteState = state_mod.AutoCompleteState; + +pub const types = @import("types.zig"); +pub const MatchMode = types.MatchMode; +pub const AutoCompleteConfig = types.AutoCompleteConfig; +pub const AutoCompleteColors = types.AutoCompleteColors; +pub const AutoCompleteResult = types.AutoCompleteResult; + +const filtering = @import("filtering.zig"); +pub const matchesFilter = filtering.matchesFilter; +pub const matchesPrefix = filtering.matchesPrefix; +pub const matchesContains = filtering.matchesContains; +pub const matchesFuzzy = filtering.matchesFuzzy; + +// ============================================================================= +// AutoComplete Functions +// ============================================================================= + +/// Draw an autocomplete widget +pub fn autocomplete( + ctx: *Context, + ac_state: *AutoCompleteState, + options: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, ac_state, options, .{}, .{}); +} + +/// Draw an autocomplete widget with custom configuration +pub fn autocompleteEx( + ctx: *Context, + ac_state: *AutoCompleteState, + options: []const []const u8, + config: AutoCompleteConfig, + colors: AutoCompleteColors, +) AutoCompleteResult { + const bounds = ctx.layout.nextRect(); + return autocompleteRect(ctx, bounds, ac_state, options, config, colors); +} + +/// Draw an autocomplete widget in a specific rectangle +pub fn autocompleteRect( + ctx: *Context, + bounds: Layout.Rect, + ac_state: *AutoCompleteState, + options: []const []const u8, + config: AutoCompleteConfig, + colors: AutoCompleteColors, +) AutoCompleteResult { + var result = AutoCompleteResult{}; + + if (bounds.isEmpty()) return result; + + // Generate unique ID for this widget based on buffer memory address + const widget_id: u64 = @intFromPtr(&ac_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); + + // Calcular área de la flecha para detección de clicks + const arrow_click_width: u32 = 20; // Zona clicable de la flecha + const arrow_area_x = bounds.x + @as(i32, @intCast(bounds.w -| arrow_click_width)); + const arrow_hovered = mouse.x >= arrow_area_x and mouse.x <= bounds.x + @as(i32, @intCast(bounds.w)) and + mouse.y >= bounds.y and mouse.y <= bounds.y + @as(i32, @intCast(bounds.h)); + const arrow_clicked = arrow_hovered and ctx.input.mousePressed(.left); + + // Handle click to request focus + if (input_clicked and !config.disabled) { + ctx.requestFocus(widget_id); + + // Click en la flecha: toggle dropdown (forzar abrir/cerrar) + if (arrow_clicked) { + if (ac_state.open) { + ac_state.closeDropdown(); + } else { + ac_state.openDropdown(); + } + } else if (config.show_on_focus) { + // Click en el área de texto: abrir si show_on_focus está activo + ac_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 = ac_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 !ac_state.was_focused) { + // Just gained focus + if (config.show_on_focus) { + ac_state.openDropdown(); + } + } else if (!is_focused and ac_state.was_focused) { + // Just lost focus - close dropdown + ac_state.closeDropdown(); + } + } + ac_state.was_focused = is_focused; + ac_state.first_frame = false; + + // Draw input field background + const border_color = if (is_focused and !config.disabled) + colors.input_border_focus + else + colors.input_border; + + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.input_bg)); + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Get current filter text + const filter_text = ac_state.text(); + + // Check if text changed (but not on first frame - that's just initialization) + const text_changed = !std.mem.eql(u8, filter_text, ac_state.last_filter[0..ac_state.last_filter_len]); + if (text_changed) { + // Update last filter + const copy_len = @min(filter_text.len, ac_state.last_filter.len); + @memcpy(ac_state.last_filter[0..copy_len], filter_text[0..copy_len]); + ac_state.last_filter_len = copy_len; + + // Only trigger changes after first frame (first frame is just sync) + if (!is_first_frame) { + result.text_changed = true; + // Reset selection when text changes + ac_state.highlighted = 0; + ac_state.scroll_offset = 0; + // Open dropdown when typing + if (filter_text.len >= config.min_chars) { + ac_state.open = true; + } + } + } + + // Draw input text or placeholder + const inner = bounds.shrink(config.padding); + const char_height: u32 = 8; + const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); + + if (filter_text.len > 0) { + const text_color = if (config.disabled) + Style.Color.rgb(120, 120, 120) + else + Style.Color.rgb(220, 220, 220); + ctx.pushCommand(Command.text(inner.x, text_y, filter_text, text_color)); + } else if (config.placeholder.len > 0) { + ctx.pushCommand(Command.text(inner.x, text_y, config.placeholder, Style.Color.rgb(100, 100, 100))); + } + + // Draw cursor if focused (same style as TextInput) + if (is_focused and !config.disabled) { + // Cursor blink logic (identical to TextInput) + const cursor_visible = blk: { + if (ctx.current_time_ms == 0) break :blk true; + const idle_time = ctx.current_time_ms -| ctx.last_input_time_ms; + if (idle_time >= Context.CURSOR_IDLE_TIMEOUT_MS) { + // Idle: cursor always visible (solid, no blink) + break :blk true; + } else { + // Active: cursor blinks + break :blk (ctx.current_time_ms / Context.CURSOR_BLINK_PERIOD_MS) % 2 == 0; + } + }; + + if (cursor_visible) { + // Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts + const cursor_offset = ctx.measureTextToCursor(filter_text, ac_state.cursor); + const cursor_x = inner.x + @as(i32, @intCast(cursor_offset)); + // Full height cursor (inner.h), same as TextInput + ctx.pushCommand(Command.rect(cursor_x, inner.y, 2, inner.h, Style.Color.rgb(200, 200, 200))); + } + } + + // Draw dropdown arrow + const arrow_size: u32 = 8; + const arrow_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.padding + arrow_size + 2)); + const arrow_y = bounds.y + @as(i32, @intCast((bounds.h -| arrow_size) / 2)); + const arrow_color = if (config.disabled) Style.Color.rgb(80, 80, 80) else Style.Color.rgb(160, 160, 160); + + ctx.pushCommand(Command.line( + arrow_x, + arrow_y, + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_color, + )); + ctx.pushCommand(Command.line( + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_x + @as(i32, @intCast(arrow_size)), + arrow_y, + arrow_color, + )); + + // Filter options + var filtered_indices: [256]usize = undefined; + var filtered_count: usize = 0; + + if (options.len == 0) { + ac_state.closeDropdown(); + } else { + for (options, 0..) |opt, i| { + if (filtered_count >= filtered_indices.len) break; + if (matchesFilter(opt, filter_text, config.match_mode, config.case_sensitive)) { + filtered_indices[filtered_count] = i; + filtered_count += 1; + } + } + } + + // Handle keyboard input when focused + if (is_focused and !config.disabled) { + // Handle text input + for (ctx.input.getKeyEvents()) |event| { + if (!event.pressed) continue; + + switch (event.key) { + .escape => { + // Escape: Cierra dropdown y RESTAURA texto original + if (ac_state.open) { + ac_state.closeDropdownAndRestore(); + } + }, + .enter => { + if (ac_state.open and ac_state.highlighted >= 0 and ac_state.highlighted < @as(i32, @intCast(filtered_count))) { + const idx = filtered_indices[@intCast(ac_state.highlighted)]; + ac_state.selected = @intCast(idx); + ac_state.setText(options[idx]); + ac_state.closeDropdown(); + result.selection_changed = true; + result.new_index = idx; + result.selected_text = options[idx]; + result.submitted = true; + result.submitted_text = options[idx]; + } else if (config.allow_custom and filter_text.len > 0) { + result.submitted = true; + result.submitted_text = filter_text; + ac_state.closeDropdown(); + } + }, + .up => { + // Ctrl+Up: Cierra dropdown (sin restaurar - confirma el estado actual) + if (ctx.input.modifiers.ctrl) { + if (ac_state.open) { + ac_state.closeDropdown(); + } + } else if (ac_state.open) { + // Navegar hacia arriba en la lista + if (ac_state.highlighted > 0) { + ac_state.highlighted -= 1; + // Scroll if needed + if (ac_state.highlighted < @as(i32, @intCast(ac_state.scroll_offset))) { + ac_state.scroll_offset = @intCast(ac_state.highlighted); + } + } + } else { + // Abrir dropdown si está cerrado + ac_state.openDropdown(); + } + }, + .down => { + // Ctrl+Down: Forzar apertura dropdown (SIN limpiar texto) + if (ctx.input.modifiers.ctrl) { + ac_state.highlighted = 0; + ac_state.scroll_offset = 0; + ac_state.openDropdown(); + } else if (ac_state.open) { + // Navegar hacia abajo en la lista + if (ac_state.highlighted < @as(i32, @intCast(filtered_count)) - 1) { + ac_state.highlighted += 1; + // Scroll if needed + const max_visible: i32 = @intCast(config.max_visible_items); + if (ac_state.highlighted >= @as(i32, @intCast(ac_state.scroll_offset)) + max_visible) { + ac_state.scroll_offset = @intCast(ac_state.highlighted - max_visible + 1); + } + } + } else { + // Abrir dropdown si está cerrado + ac_state.openDropdown(); + } + }, + .tab => { + // Accept current highlight on Tab + if (ac_state.open and ac_state.highlighted >= 0 and ac_state.highlighted < @as(i32, @intCast(filtered_count))) { + const idx = filtered_indices[@intCast(ac_state.highlighted)]; + ac_state.selected = @intCast(idx); + ac_state.setText(options[idx]); + ac_state.closeDropdown(); + result.selection_changed = true; + result.new_index = idx; + result.selected_text = options[idx]; + } + }, + .backspace => { + ac_state.backspace(); + // Abrir dropdown después de borrar (el usuario está editando) + if (ac_state.len >= config.min_chars) { + ac_state.open = true; + } + }, + .delete => { + ac_state.delete(); + // Abrir dropdown después de borrar + if (ac_state.len >= config.min_chars) { + ac_state.open = true; + } + }, + .left => { + if (!ac_state.open) { + ac_state.moveCursor(-1); + } + }, + .right => { + if (!ac_state.open) { + ac_state.moveCursor(1); + } + }, + .home => { + ac_state.cursor = 0; + }, + .end => { + ac_state.cursor = ac_state.len; + }, + else => {}, + } + } + + // Handle typed text (after key events, like TextInput does) + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + ac_state.insert(text_in); + result.text_changed = true; + // IMPORTANTE: Abrir dropdown inmediatamente después de insertar texto + // (no esperar al siguiente frame para detectar el cambio) + if (ac_state.len >= config.min_chars) { + ac_state.open = true; + } + } + } + + // Draw dropdown if open and has items + // OVERLAY: El dropdown se dibuja en la capa overlay para aparecer ENCIMA de otros widgets + if (ac_state.open and filtered_count > 0) { + const visible_items = @min(filtered_count, config.max_visible_items); + const dropdown_h = visible_items * config.item_height; + const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); + + // Dropdown background (overlay) + ctx.pushOverlayCommand(Command.rect( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + colors.dropdown_bg, + )); + + ctx.pushOverlayCommand(Command.rectOutline( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + colors.input_border, + )); + + // Draw visible items + var item_y = dropdown_y; + const start = ac_state.scroll_offset; + const end = @min(start + visible_items, filtered_count); + + for (start..end) |fi| { + const i = filtered_indices[fi]; + const item_bounds = Layout.Rect.init( + bounds.x, + item_y, + bounds.w, + config.item_height, + ); + + const item_hovered = item_bounds.contains(mouse.x, mouse.y); + const item_clicked = item_hovered and ctx.input.mousePressed(.left); + const is_highlighted = ac_state.highlighted == @as(i32, @intCast(fi)); + const is_selected = ac_state.selected == @as(i32, @intCast(i)); + + // Update highlight on hover + if (item_hovered) { + ac_state.highlighted = @intCast(fi); + } + + // Item background + const item_bg = if (is_highlighted) + colors.highlight_bg + else if (is_selected) + colors.selected_bg + else + Style.Color.transparent; + + if (item_bg.a > 0) { + ctx.pushOverlayCommand(Command.rect( + item_bounds.x + 1, + item_bounds.y, + item_bounds.w - 2, + item_bounds.h, + item_bg, + )); + } + + // Item text (overlay) + const item_inner = item_bounds.shrink(config.padding); + const item_text_y = item_inner.y + @as(i32, @intCast((item_inner.h -| char_height) / 2)); + + ctx.pushOverlayCommand(Command.text(item_inner.x, item_text_y, options[i], Style.Color.rgb(220, 220, 220))); + + // Handle click selection + if (item_clicked) { + ac_state.selected = @intCast(i); + ac_state.setText(options[i]); + ac_state.closeDropdown(); + result.selection_changed = true; + result.new_index = i; + result.selected_text = options[i]; + } + + item_y += @as(i32, @intCast(config.item_height)); + } + + // Close dropdown if clicked outside + if (ctx.input.mousePressed(.left) and !input_hovered) { + const dropdown_bounds = Layout.Rect.init( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + ); + if (!dropdown_bounds.contains(mouse.x, mouse.y)) { + ac_state.closeDropdown(); + } + } + } else if (ac_state.open and filtered_count == 0 and filter_text.len > 0) { + // Show "no matches" message (overlay) + const no_match_h: u32 = config.item_height; + const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); + + ctx.pushOverlayCommand(Command.rect( + bounds.x, + dropdown_y, + bounds.w, + no_match_h, + colors.dropdown_bg, + )); + + ctx.pushOverlayCommand(Command.rectOutline( + bounds.x, + dropdown_y, + bounds.w, + no_match_h, + colors.input_border, + )); + + const no_match_text = if (config.allow_custom) "Press Enter to use custom value" else "No matches found"; + const msg_y = dropdown_y + @as(i32, @intCast((no_match_h -| char_height) / 2)); + ctx.pushOverlayCommand(Command.text(bounds.x + @as(i32, @intCast(config.padding)), msg_y, no_match_text, Style.Color.rgb(120, 120, 120))); + + // Close if clicked outside + if (ctx.input.mousePressed(.left) and !input_hovered) { + const dropdown_bounds = Layout.Rect.init(bounds.x, dropdown_y, bounds.w, no_match_h); + if (!dropdown_bounds.contains(mouse.x, mouse.y)) { + ac_state.closeDropdown(); + } + } + } + + return result; +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/// Create a province autocomplete (common use case) +pub fn provinceAutocomplete( + ctx: *Context, + ac_state: *AutoCompleteState, + provinces: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, ac_state, provinces, .{ + .placeholder = "Select province...", + .match_mode = .contains, + .case_sensitive = false, + .min_chars = 0, + }, .{}); +} + +/// Create a country autocomplete +pub fn countryAutocomplete( + ctx: *Context, + ac_state: *AutoCompleteState, + countries: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, ac_state, countries, .{ + .placeholder = "Select country...", + .match_mode = .prefix, + .case_sensitive = false, + .min_chars = 1, + }, .{}); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "AutoCompleteState init" { + var ac_state = AutoCompleteState.init(); + try std.testing.expectEqual(@as(usize, 0), ac_state.text().len); + try std.testing.expect(!ac_state.open); + try std.testing.expectEqual(@as(i32, -1), ac_state.selected); +} + +test "AutoCompleteState setText" { + var ac_state = AutoCompleteState.init(); + ac_state.setText("Madrid"); + try std.testing.expectEqualStrings("Madrid", ac_state.text()); +} + +test "autocomplete generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var ac_state = AutoCompleteState.init(); + const options = [_][]const u8{ "Madrid", "Barcelona", "Valencia" }; + + ctx.beginFrame(); + ctx.layout.row_height = 30; + + _ = autocomplete(&ctx, &ac_state, &options); + + // Should generate: rect (bg) + rect_outline (border) + text (placeholder) + 2 lines (arrow) + try std.testing.expect(ctx.commands.items.len >= 4); + + ctx.endFrame(); +} diff --git a/src/widgets/autocomplete/filtering.zig b/src/widgets/autocomplete/filtering.zig new file mode 100644 index 0000000..13c1eb2 --- /dev/null +++ b/src/widgets/autocomplete/filtering.zig @@ -0,0 +1,106 @@ +//! AutoComplete - Funciones de filtrado +//! +//! Helpers para matching: prefix, contains, fuzzy. + +const std = @import("std"); +const types = @import("types.zig"); +const MatchMode = types.MatchMode; + +// ============================================================================= +// Filtering Helpers +// ============================================================================= + +/// Check if option matches filter +pub fn matchesFilter(option: []const u8, filter: []const u8, mode: MatchMode, case_sensitive: bool) bool { + if (filter.len == 0) return true; + + return switch (mode) { + .prefix => matchesPrefix(option, filter, case_sensitive), + .contains => matchesContains(option, filter, case_sensitive), + .fuzzy => matchesFuzzy(option, filter, case_sensitive), + }; +} + +pub fn matchesPrefix(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + if (filter.len > option.len) return false; + + if (case_sensitive) { + return std.mem.startsWith(u8, option, filter); + } else { + for (0..filter.len) |i| { + if (std.ascii.toLower(option[i]) != std.ascii.toLower(filter[i])) { + return false; + } + } + return true; + } +} + +pub fn matchesContains(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + if (filter.len > option.len) return false; + + if (case_sensitive) { + return std.mem.indexOf(u8, option, filter) != null; + } else { + // Case insensitive contains + const option_len = option.len; + const filter_len = filter.len; + + var i: usize = 0; + while (i + filter_len <= option_len) : (i += 1) { + var matches = true; + for (0..filter_len) |j| { + if (std.ascii.toLower(option[i + j]) != std.ascii.toLower(filter[j])) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; + } +} + +pub fn matchesFuzzy(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + // Fuzzy: each filter char must appear in order + var filter_idx: usize = 0; + + for (option) |c| { + if (filter_idx >= filter.len) break; + + const fc = filter[filter_idx]; + const matches = if (case_sensitive) + c == fc + else + std.ascii.toLower(c) == std.ascii.toLower(fc); + + if (matches) { + filter_idx += 1; + } + } + + return filter_idx >= filter.len; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "matchesFilter prefix" { + try std.testing.expect(matchesPrefix("Madrid", "Mad", false)); + try std.testing.expect(matchesPrefix("Madrid", "mad", false)); + try std.testing.expect(!matchesPrefix("Barcelona", "Mad", false)); + try std.testing.expect(!matchesPrefix("Madrid", "Mad", true) == false); // case sensitive, exact match +} + +test "matchesFilter contains" { + try std.testing.expect(matchesContains("Madrid", "dri", false)); + try std.testing.expect(matchesContains("Madrid", "DRI", false)); + try std.testing.expect(!matchesContains("Barcelona", "dri", false)); +} + +test "matchesFilter fuzzy" { + try std.testing.expect(matchesFuzzy("Madrid", "mrd", false)); // m-a-d-r-i-d contains m, r, d in order + try std.testing.expect(matchesFuzzy("Barcelona", "bcn", false)); // b-a-r-c-e-l-o-n-a contains b, c, n in order + try std.testing.expect(!matchesFuzzy("Madrid", "xyz", false)); +} diff --git a/src/widgets/autocomplete/state.zig b/src/widgets/autocomplete/state.zig new file mode 100644 index 0000000..e9f1fa5 --- /dev/null +++ b/src/widgets/autocomplete/state.zig @@ -0,0 +1,200 @@ +//! AutoComplete - Estado del widget +//! +//! AutoCompleteState: buffer de texto, cursor, gestión de dropdown. + +const std = @import("std"); + +// ============================================================================= +// AutoComplete State +// ============================================================================= + +/// AutoComplete state (caller-managed) +pub const AutoCompleteState = struct { + /// Internal text buffer + buffer: [256]u8 = [_]u8{0} ** 256, + /// Text length + len: usize = 0, + /// Cursor position + cursor: usize = 0, + /// Currently selected index in filtered list (-1 for none) + selected: i32 = -1, + /// Whether dropdown is open + open: bool = false, + /// Highlighted item in dropdown (for keyboard navigation) + highlighted: i32 = -1, + /// Scroll offset in dropdown + scroll_offset: usize = 0, + /// 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, + /// Texto original guardado al abrir dropdown (para restaurar con Escape) + saved_text: [256]u8 = [_]u8{0} ** 256, + saved_text_len: usize = 0, + saved_cursor: usize = 0, + + const Self = @This(); + + /// Initialize state + pub fn init() Self { + return .{}; + } + + /// Get current input text + pub fn text(self: *const Self) []const u8 { + return self.buffer[0..self.len]; + } + + /// Set text programmatically + pub fn setText(self: *Self, 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; + + // Sync filter to avoid spurious text_changed events + @memcpy(self.last_filter[0..copy_len], new_text[0..copy_len]); + self.last_filter_len = copy_len; + } + + /// Clear the input + pub fn clear(self: *Self) void { + self.len = 0; + self.cursor = 0; + self.selected = -1; + self.highlighted = -1; + self.open = false; + self.last_filter_len = 0; + } + + /// Insert a single character at cursor + pub fn insertChar(self: *Self, c: u8) void { + if (self.len >= self.buffer.len) return; + + // Move text after cursor + if (self.cursor < self.len) { + std.mem.copyBackwards( + u8, + self.buffer[self.cursor + 1 .. self.len + 1], + self.buffer[self.cursor..self.len], + ); + } + + self.buffer[self.cursor] = c; + self.len += 1; + 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; + + // Move text after cursor back + if (self.cursor < self.len) { + 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 delete(self: *Self) void { + if (self.cursor >= self.len) return; + + // Move text after cursor back + if (self.cursor + 1 < self.len) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor .. self.len - 1], + self.buffer[self.cursor + 1 .. self.len], + ); + } + + self.len -= 1; + } + + /// Move cursor + pub fn moveCursor(self: *Self, delta: i32) void { + if (delta < 0) { + const abs: usize = @intCast(-delta); + if (abs > self.cursor) { + self.cursor = 0; + } else { + self.cursor -= abs; + } + } else { + const abs: usize = @intCast(delta); + self.cursor = @min(self.cursor + abs, self.len); + } + } + + /// Guarda el texto actual (para restaurar con Escape) + pub fn saveText(self: *Self) void { + @memcpy(self.saved_text[0..self.len], self.buffer[0..self.len]); + self.saved_text_len = self.len; + self.saved_cursor = self.cursor; + } + + /// Restaura el texto guardado + pub fn restoreText(self: *Self) void { + @memcpy(self.buffer[0..self.saved_text_len], self.saved_text[0..self.saved_text_len]); + self.len = self.saved_text_len; + self.cursor = self.saved_cursor; + // Sync filter + @memcpy(self.last_filter[0..self.saved_text_len], self.saved_text[0..self.saved_text_len]); + self.last_filter_len = self.saved_text_len; + } + + /// Open the dropdown (guarda texto para posible restauración con Escape) + pub fn openDropdown(self: *Self) void { + if (!self.open) { + // Solo guardar si estamos abriendo (no si ya está abierto) + self.saveText(); + } + self.open = true; + self.highlighted = if (self.selected >= 0) self.selected else 0; + } + + /// Close the dropdown + pub fn closeDropdown(self: *Self) void { + self.open = false; + self.highlighted = -1; + } + + /// Close dropdown y restaurar texto original (para Escape) + pub fn closeDropdownAndRestore(self: *Self) void { + self.restoreText(); + self.closeDropdown(); + } +}; diff --git a/src/widgets/autocomplete/types.zig b/src/widgets/autocomplete/types.zig new file mode 100644 index 0000000..88e79eb --- /dev/null +++ b/src/widgets/autocomplete/types.zig @@ -0,0 +1,89 @@ +//! AutoComplete - Tipos y configuración +//! +//! Tipos compartidos: MatchMode, Config, Colors, Result. + +const Style = @import("../../core/style.zig"); + +// ============================================================================= +// Match Mode +// ============================================================================= + +/// Match mode for filtering +pub const MatchMode = enum { + /// Match if option starts with filter text + prefix, + /// Match if option contains filter text anywhere + contains, + /// Match using fuzzy matching (characters in order) + fuzzy, +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/// AutoComplete configuration +pub const AutoCompleteConfig = struct { + /// Placeholder text when empty + placeholder: []const u8 = "Type to search...", + /// Disabled state + disabled: bool = false, + /// Maximum visible items in dropdown + max_visible_items: usize = 8, + /// Height of each item + item_height: u32 = 24, + /// Padding + padding: u32 = 4, + /// Match mode for filtering + match_mode: MatchMode = .contains, + /// Case sensitive matching + case_sensitive: bool = false, + /// Allow free-form text (not just from options) + allow_custom: bool = false, + /// Minimum characters before showing suggestions + min_chars: usize = 0, + /// Show dropdown on focus (even if empty) + show_on_focus: bool = true, +}; + +// ============================================================================= +// Colors +// ============================================================================= + +/// AutoComplete colors +pub const AutoCompleteColors = struct { + /// Input background + input_bg: Style.Color = Style.Color.rgb(35, 35, 40), + /// Input border + input_border: Style.Color = Style.Color.rgb(80, 80, 85), + /// Input border when focused + input_border_focus: Style.Color = Style.Color.primary, + /// Dropdown background + dropdown_bg: Style.Color = Style.Color.rgb(45, 45, 50), + /// Highlighted item background + highlight_bg: Style.Color = Style.Color.rgb(60, 60, 70), + /// Selected item background + selected_bg: Style.Color = Style.Color.rgb(70, 100, 140), + /// Match highlight color (for showing matching part) + match_fg: Style.Color = Style.Color.primary, +}; + +// ============================================================================= +// Result +// ============================================================================= + +/// AutoComplete result +pub const AutoCompleteResult = struct { + /// Selection changed this frame (from dropdown) + selection_changed: bool = false, + /// Newly selected index (valid if selection_changed) + new_index: ?usize = null, + /// Selected text (valid if selection_changed) + selected_text: ?[]const u8 = null, + /// Text was submitted (Enter pressed with valid selection or custom allowed) + submitted: bool = false, + /// Submitted text + submitted_text: ?[]const u8 = null, + /// Text changed (user typed) + text_changed: bool = false, +}; diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 0fc4050..20b4783 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -18,7 +18,7 @@ pub const table = @import("table/table.zig"); pub const split = @import("split.zig"); pub const panel = @import("panel.zig"); pub const modal = @import("modal.zig"); -pub const autocomplete = @import("autocomplete.zig"); +pub const autocomplete = @import("autocomplete/autocomplete.zig"); pub const slider = @import("slider.zig"); pub const scroll = @import("scroll.zig"); pub const menu = @import("menu.zig");