refactor(autocomplete): Modularizar en carpeta (910→571 LOC hub)

- 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
This commit is contained in:
reugenio 2025-12-29 11:27:37 +01:00
parent 7d4d4190b8
commit 61f0524bd3
6 changed files with 967 additions and 911 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
};

View file

@ -18,7 +18,7 @@ pub const table = @import("table/table.zig");
pub const split = @import("split.zig"); pub const split = @import("split.zig");
pub const panel = @import("panel.zig"); pub const panel = @import("panel.zig");
pub const modal = @import("modal.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 slider = @import("slider.zig");
pub const scroll = @import("scroll.zig"); pub const scroll = @import("scroll.zig");
pub const menu = @import("menu.zig"); pub const menu = @import("menu.zig");