feat(autocomplete): Sistema overlay para dropdowns + mejoras UX
OVERLAY SYSTEM: - Context: nuevo campo overlay_commands para capa superior - Context: nueva función pushOverlayCommand() - mainloop: renderiza overlay_commands DESPUÉS de commands - autocomplete: dropdown usa overlay (se dibuja encima de otros widgets) MEJORAS AUTOCOMPLETE: - Dropdown se abre inmediatamente al escribir (sin delay de 1 frame) - Dropdown se abre al borrar con backspace/delete - Click en flecha del dropdown hace toggle abrir/cerrar - Área clicable de flecha: 20px ESTADO: En progreso - funcionalidad básica operativa, pendiente: - Keyboard handlers completos - Más testing de casos edge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fdda6ba1a4
commit
3f442bf8b9
3 changed files with 63 additions and 14 deletions
|
|
@ -60,6 +60,9 @@ pub const Context = struct {
|
|||
/// Draw commands for current frame
|
||||
commands: std.ArrayListUnmanaged(Command.DrawCommand),
|
||||
|
||||
/// Overlay commands (drawn AFTER all regular commands - for dropdowns, tooltips, popups)
|
||||
overlay_commands: std.ArrayListUnmanaged(Command.DrawCommand),
|
||||
|
||||
/// Input state
|
||||
input: Input.InputState,
|
||||
|
||||
|
|
@ -136,6 +139,7 @@ pub const Context = struct {
|
|||
.allocator = allocator,
|
||||
.frame_arena = try FrameArena.init(allocator),
|
||||
.commands = .{},
|
||||
.overlay_commands = .{},
|
||||
.input = Input.InputState.init(),
|
||||
.layout = Layout.LayoutState.init(width, height),
|
||||
.id_stack = .{},
|
||||
|
|
@ -155,6 +159,7 @@ pub const Context = struct {
|
|||
.allocator = allocator,
|
||||
.frame_arena = try FrameArena.initWithSize(allocator, arena_size),
|
||||
.commands = .{},
|
||||
.overlay_commands = .{},
|
||||
.input = Input.InputState.init(),
|
||||
.layout = Layout.LayoutState.init(width, height),
|
||||
.id_stack = .{},
|
||||
|
|
@ -171,6 +176,7 @@ pub const Context = struct {
|
|||
/// Clean up resources
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.commands.deinit(self.allocator);
|
||||
self.overlay_commands.deinit(self.allocator);
|
||||
self.id_stack.deinit(self.allocator);
|
||||
self.dirty_rects.deinit(self.allocator);
|
||||
self.frame_arena.deinit();
|
||||
|
|
@ -183,6 +189,7 @@ pub const Context = struct {
|
|||
|
||||
// Reset per-frame state
|
||||
self.commands.clearRetainingCapacity();
|
||||
self.overlay_commands.clearRetainingCapacity();
|
||||
self.id_stack.clearRetainingCapacity();
|
||||
self.dirty_rects.clearRetainingCapacity();
|
||||
self.layout.reset(self.width, self.height);
|
||||
|
|
@ -212,8 +219,8 @@ pub const Context = struct {
|
|||
|
||||
self.input.endFrame();
|
||||
|
||||
// Update final stats
|
||||
self.stats.command_count = self.commands.items.len;
|
||||
// Update final stats (includes overlay commands)
|
||||
self.stats.command_count = self.commands.items.len + self.overlay_commands.items.len;
|
||||
self.stats.arena_bytes = self.frame_arena.bytesUsed();
|
||||
self.stats.dirty_rect_count = self.dirty_rects.items.len;
|
||||
|
||||
|
|
@ -395,6 +402,12 @@ pub const Context = struct {
|
|||
self.commands.append(self.allocator, cmd) catch {};
|
||||
}
|
||||
|
||||
/// Push an overlay command (drawn AFTER all regular commands)
|
||||
/// Use this for dropdowns, tooltips, popups that need to appear on top
|
||||
pub fn pushOverlayCommand(self: *Self, cmd: Command.DrawCommand) void {
|
||||
self.overlay_commands.append(self.allocator, cmd) catch {};
|
||||
}
|
||||
|
||||
/// Resize the context
|
||||
pub fn resize(self: *Self, width: u32, height: u32) void {
|
||||
self.width = width;
|
||||
|
|
|
|||
|
|
@ -214,6 +214,11 @@ pub const MainLoop = struct {
|
|||
// Execute draw commands
|
||||
self.renderer.executeAll(self.ctx.commands.items);
|
||||
|
||||
// Execute overlay commands (dropdowns, tooltips, popups - drawn on top)
|
||||
if (self.ctx.overlay_commands.items.len > 0) {
|
||||
self.renderer.executeAll(self.ctx.overlay_commands.items);
|
||||
}
|
||||
|
||||
self.ctx.endFrame();
|
||||
|
||||
// Present to backend
|
||||
|
|
@ -222,9 +227,10 @@ pub const MainLoop = struct {
|
|||
if (self.config.debug_timing) {
|
||||
const elapsed_ns = std.time.nanoTimestamp() - start_time;
|
||||
const elapsed_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0;
|
||||
const total_cmds = self.ctx.commands.items.len + self.ctx.overlay_commands.items.len;
|
||||
std.debug.print("Frame {}: {} cmds, {d:.1}ms\n", .{
|
||||
self.frame_count,
|
||||
self.ctx.commands.items.len,
|
||||
total_cmds,
|
||||
elapsed_ms,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,10 +297,26 @@ pub fn autocompleteRect(
|
|||
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);
|
||||
if (config.show_on_focus) {
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -504,9 +520,17 @@ pub fn autocompleteRect(
|
|||
},
|
||||
.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) {
|
||||
|
|
@ -533,17 +557,23 @@ pub fn autocompleteRect(
|
|||
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
|
||||
ctx.pushCommand(Command.rect(
|
||||
// Dropdown background (overlay)
|
||||
ctx.pushOverlayCommand(Command.rect(
|
||||
bounds.x,
|
||||
dropdown_y,
|
||||
bounds.w,
|
||||
|
|
@ -551,7 +581,7 @@ pub fn autocompleteRect(
|
|||
colors.dropdown_bg,
|
||||
));
|
||||
|
||||
ctx.pushCommand(Command.rectOutline(
|
||||
ctx.pushOverlayCommand(Command.rectOutline(
|
||||
bounds.x,
|
||||
dropdown_y,
|
||||
bounds.w,
|
||||
|
|
@ -592,7 +622,7 @@ pub fn autocompleteRect(
|
|||
Style.Color.transparent;
|
||||
|
||||
if (item_bg.a > 0) {
|
||||
ctx.pushCommand(Command.rect(
|
||||
ctx.pushOverlayCommand(Command.rect(
|
||||
item_bounds.x + 1,
|
||||
item_bounds.y,
|
||||
item_bounds.w - 2,
|
||||
|
|
@ -601,11 +631,11 @@ pub fn autocompleteRect(
|
|||
));
|
||||
}
|
||||
|
||||
// Item text
|
||||
// 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.pushCommand(Command.text(item_inner.x, item_text_y, options[i], Style.Color.rgb(220, 220, 220)));
|
||||
ctx.pushOverlayCommand(Command.text(item_inner.x, item_text_y, options[i], Style.Color.rgb(220, 220, 220)));
|
||||
|
||||
// Handle click selection
|
||||
if (item_clicked) {
|
||||
|
|
@ -633,11 +663,11 @@ pub fn autocompleteRect(
|
|||
}
|
||||
}
|
||||
} else if (state.open and filtered_count == 0 and filter_text.len > 0) {
|
||||
// Show "no matches" message
|
||||
// Show "no matches" message (overlay)
|
||||
const no_match_h: u32 = config.item_height;
|
||||
const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h));
|
||||
|
||||
ctx.pushCommand(Command.rect(
|
||||
ctx.pushOverlayCommand(Command.rect(
|
||||
bounds.x,
|
||||
dropdown_y,
|
||||
bounds.w,
|
||||
|
|
@ -645,7 +675,7 @@ pub fn autocompleteRect(
|
|||
colors.dropdown_bg,
|
||||
));
|
||||
|
||||
ctx.pushCommand(Command.rectOutline(
|
||||
ctx.pushOverlayCommand(Command.rectOutline(
|
||||
bounds.x,
|
||||
dropdown_y,
|
||||
bounds.w,
|
||||
|
|
@ -655,7 +685,7 @@ pub fn autocompleteRect(
|
|||
|
||||
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.pushCommand(Command.text(bounds.x + @as(i32, @intCast(config.padding)), msg_y, no_match_text, Style.Color.rgb(120, 120, 120)));
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue