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 panel = @import("panel.zig");
|
||||
pub const modal = @import("modal.zig");
|
||||
pub const autocomplete = @import("autocomplete.zig");
|
||||
pub const autocomplete = @import("autocomplete/autocomplete.zig");
|
||||
pub const slider = @import("slider.zig");
|
||||
pub const scroll = @import("scroll.zig");
|
||||
pub const menu = @import("menu.zig");
|
||||
|
|
|
|||
Loading…
Reference in a new issue