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:
parent
7d4d4190b8
commit
61f0524bd3
6 changed files with 967 additions and 911 deletions
|
|
@ -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();
|
|
||||||
}
|
|
||||||
571
src/widgets/autocomplete/autocomplete.zig
Normal file
571
src/widgets/autocomplete/autocomplete.zig
Normal 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();
|
||||||
|
}
|
||||||
106
src/widgets/autocomplete/filtering.zig
Normal file
106
src/widgets/autocomplete/filtering.zig
Normal 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));
|
||||||
|
}
|
||||||
200
src/widgets/autocomplete/state.zig
Normal file
200
src/widgets/autocomplete/state.zig
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
89
src/widgets/autocomplete/types.zig
Normal file
89
src/widgets/autocomplete/types.zig
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue