Compare commits

...

2 commits

Author SHA1 Message Date
R.Eugenio
59d102315d fix(memory): Deep clone en Row + CellValue para evitar dangling pointers
- CellValue.clone(): duplica strings en lugar de copiar punteros
- CellValue.deinit(): libera strings clonados
- Row.owns_data: flag para indicar propiedad de memoria
- Row.clone(): ahora hace deep copy completo
- Row.deinit(): libera strings si owns_data=true

Soluciona crash al guardar cliente sin población/provincia/país,
donde loadWhoList() liberaba strings que Row clonada seguía referenciando.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 13:16:59 +01:00
3f442bf8b9 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>
2025-12-20 19:07:44 +01:00
4 changed files with 155 additions and 20 deletions

View file

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

View file

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

View file

@ -158,6 +158,22 @@ pub const CellValue = union(enum) {
.boolean => |b| if (b) "Yes" else "No",
};
}
/// Clone value with deep copy of strings
pub fn clone(self: CellValue, allocator: std.mem.Allocator) !CellValue {
return switch (self) {
.string => |s| .{ .string = if (s.len > 0) try allocator.dupe(u8, s) else "" },
else => self, // Other types are value types, no allocation needed
};
}
/// Free allocated string (only call for cloned values)
pub fn deinit(self: CellValue, allocator: std.mem.Allocator) void {
switch (self) {
.string => |s| if (s.len > 0) allocator.free(s),
else => {},
}
}
};
// =============================================================================
@ -452,15 +468,26 @@ pub const OnActiveRowChangedFn = *const fn (old_row: ?usize, new_row: usize, row
pub const Row = struct {
data: std.StringHashMap(CellValue),
allocator: std.mem.Allocator,
/// True if this Row owns the string data (was created via clone)
/// When true, deinit() will free the strings
owns_data: bool = false,
pub fn init(allocator: std.mem.Allocator) Row {
return .{
.data = std.StringHashMap(CellValue).init(allocator),
.allocator = allocator,
.owns_data = false,
};
}
pub fn deinit(self: *Row) void {
// Free cloned string values if we own them
if (self.owns_data) {
var iter = self.data.iterator();
while (iter.next()) |entry| {
entry.value_ptr.*.deinit(self.allocator);
}
}
self.data.deinit();
}
@ -472,11 +499,22 @@ pub const Row = struct {
try self.data.put(column, value);
}
/// Deep clone: duplicates all string values
/// The cloned Row owns its data and will free it on deinit()
pub fn clone(self: *const Row, allocator: std.mem.Allocator) !Row {
var new_row = Row.init(allocator);
var new_row = Row{
.data = std.StringHashMap(CellValue).init(allocator),
.allocator = allocator,
.owns_data = true, // Cloned row owns its string data
};
errdefer new_row.deinit();
var iter = self.data.iterator();
while (iter.next()) |entry| {
try new_row.data.put(entry.key_ptr.*, entry.value_ptr.*);
// Keys are typically string literals, no need to clone
// Values need deep clone for strings
const cloned_value = try entry.value_ptr.*.clone(allocator);
try new_row.data.put(entry.key_ptr.*, cloned_value);
}
return new_row;
}

View file

@ -40,6 +40,10 @@ pub const AutoCompleteState = struct {
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();
@ -164,8 +168,29 @@ pub const AutoCompleteState = struct {
}
}
/// Open the dropdown
/// 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;
}
@ -175,6 +200,12 @@ pub const AutoCompleteState = struct {
self.open = false;
self.highlighted = -1;
}
/// Close dropdown y restaurar texto original (para Escape)
pub fn closeDropdownAndRestore(self: *Self) void {
self.restoreText();
self.closeDropdown();
}
};
// =============================================================================
@ -297,10 +328,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();
}
}
@ -444,7 +491,10 @@ pub fn autocompleteRect(
switch (event.key) {
.escape => {
state.closeDropdown();
// 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))) {
@ -464,7 +514,13 @@ pub fn autocompleteRect(
}
},
.up => {
if (state.open) {
// 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
@ -473,11 +529,18 @@ pub fn autocompleteRect(
}
}
} else {
// Abrir dropdown si está cerrado
state.openDropdown();
}
},
.down => {
if (state.open) {
// 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
@ -487,6 +550,7 @@ pub fn autocompleteRect(
}
}
} else {
// Abrir dropdown si está cerrado
state.openDropdown();
}
},
@ -504,9 +568,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 +605,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 +629,7 @@ pub fn autocompleteRect(
colors.dropdown_bg,
));
ctx.pushCommand(Command.rectOutline(
ctx.pushOverlayCommand(Command.rectOutline(
bounds.x,
dropdown_y,
bounds.w,
@ -592,7 +670,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 +679,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 +711,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 +723,7 @@ pub fn autocompleteRect(
colors.dropdown_bg,
));
ctx.pushCommand(Command.rectOutline(
ctx.pushOverlayCommand(Command.rectOutline(
bounds.x,
dropdown_y,
bounds.w,
@ -655,7 +733,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) {