Compare commits

...

3 commits

Author SHA1 Message Date
f077c87dfc feat(v0.22.2): AutoComplete focus + Text Metrics + cursor 300ms
AutoComplete:
- Integración sistema focus (registerFocusable, requestFocus, hasFocus)
- Fix input: event.char → ctx.input.getTextInput()
- Fix dropdown al iniciar: is_first_frame guard

Text Metrics:
- Nuevo ctx.measureText() y ctx.measureTextToCursor()
- text_measure_fn callback para fuentes TTF
- char_width fallback para bitmap (8px)

Cursor:
- text_input.zig y autocomplete.zig usan métricas reales
- Blink rate: 500ms → 300ms (más responsive)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:11 +01:00
a377a00803 cleanup: Eliminar prints de debug de investigación fondo azul
Eliminados std.debug.print con números mágicos 99999 y debug de
focus/colores añadidos durante investigación del bug.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:51:54 +01:00
3d44631cc3 fix: Eliminar código olvidado en drawRoundedRect que rellenaba área
Bug crítico: Al dar focus a cualquier widget, TODO el fondo se pintaba
de azul semitransparente, no solo el borde de focus.

Causa: En drawRoundedRect() había código de una implementación anterior
que hacía fillRoundedRect() antes de dibujar el outline. Cuando focusRing
llamaba a drawRoundedRect con color azul, primero rellenaba todo el área.

Fix: Eliminadas 8 líneas de código obsoleto (comentarios de estrategia
abandonada + la llamada a fillRoundedRect).

También: Limpieza de código debug en advanced_table.zig.

Crédito: Bug encontrado por Gemini tras descripción del problema.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:48:30 +01:00
6 changed files with 143 additions and 38 deletions

View file

@ -39,6 +39,9 @@
| 2025-12-17 | v0.21.0 | AdvancedTable: +990 LOC (multi-select, search, validation) | | 2025-12-17 | v0.21.0 | AdvancedTable: +990 LOC (multi-select, search, validation) |
| 2025-12-17 | v0.21.1 | Fix: AdvancedTable teclado - result.selected_row/col en handleKeyboard | | 2025-12-17 | v0.21.1 | Fix: AdvancedTable teclado - result.selected_row/col en handleKeyboard |
| 2025-12-19 | v0.21.2 | AdvancedTable: selected_row_unfocus, color selección según focus | | 2025-12-19 | v0.21.2 | AdvancedTable: selected_row_unfocus, color selección según focus |
| 2025-12-19 | v0.22.0 | ⭐ AutoComplete: focus system integration, getTextInput(), first_frame guard |
| 2025-12-19 | v0.22.1 | ⭐ Text Metrics: ctx.measureText/measureTextToCursor para fuentes TTF de ancho variable |
| 2025-12-19 | v0.22.2 | Cursor blink rate: 500ms→300ms (más responsive durante edición) |
--- ---
@ -55,3 +58,9 @@ Sistema de rendering dual (simple/fancy), esquinas redondeadas, sombras, transic
### v0.20.0-v0.21.1 - AdvancedTable (2025-12-17) ### v0.20.0-v0.21.1 - AdvancedTable (2025-12-17)
Widget de tabla avanzada con schema, CRUD, sorting, lookup, multi-select, search, validation. Widget de tabla avanzada con schema, CRUD, sorting, lookup, multi-select, search, validation.
→ Detalle: `docs/ADVANCED_TABLE_MERGE_PLAN.md` → Detalle: `docs/ADVANCED_TABLE_MERGE_PLAN.md`
### v0.22.0-v0.22.2 - AutoComplete + Text Metrics (2025-12-19)
- **AutoComplete**: Integración completa con sistema de focus (registerFocusable, requestFocus, hasFocus)
- **Text Metrics**: Nuevo sistema ctx.measureText() para posicionamiento correcto del cursor con fuentes TTF
- **Cursor**: Velocidad de parpadeo aumentada (500ms→300ms) para mejor feedback durante edición
→ Archivos: `context.zig`, `text_input.zig`, `autocomplete.zig`

View file

@ -99,12 +99,20 @@ pub const Context = struct {
/// Used for idle detection (e.g., cursor stops blinking after inactivity) /// Used for idle detection (e.g., cursor stops blinking after inactivity)
last_input_time_ms: u64 = 0, last_input_time_ms: u64 = 0,
/// Optional text measurement function (set by application with TTF font)
/// Returns pixel width of text. If null, falls back to char_width * len.
text_measure_fn: ?*const fn ([]const u8) u32 = null,
/// Default character width for fallback measurement (bitmap fonts)
char_width: u32 = 8,
/// Idle timeout for cursor blinking (ms). After this time without input, /// Idle timeout for cursor blinking (ms). After this time without input,
/// cursor becomes solid and no animation frames are needed. /// cursor becomes solid and no animation frames are needed.
pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 5000; pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 5000;
/// Cursor blink period (ms). Cursor toggles visibility at this rate. /// Cursor blink period (ms). Cursor toggles visibility at this rate.
pub const CURSOR_BLINK_PERIOD_MS: u64 = 500; /// 300ms = ~3.3 blinks/sec (faster for better editing feedback)
pub const CURSOR_BLINK_PERIOD_MS: u64 = 300;
const Self = @This(); const Self = @This();
@ -285,6 +293,38 @@ pub const Context = struct {
self.current_time_ms = time_ms; self.current_time_ms = time_ms;
} }
// =========================================================================
// Text Metrics
// =========================================================================
/// Measure text width in pixels.
/// Uses TTF font metrics if text_measure_fn is set, otherwise falls back to char_width * len.
pub fn measureText(self: *const Self, text: []const u8) u32 {
if (self.text_measure_fn) |measure_fn| {
return measure_fn(text);
}
// Fallback: fixed-width calculation
return @as(u32, @intCast(text.len)) * self.char_width;
}
/// Measure text width up to cursor position (for cursor placement).
/// text: the full text
/// cursor_pos: character position (byte index for ASCII, needs UTF-8 handling for unicode)
pub fn measureTextToCursor(self: *const Self, text: []const u8, cursor_pos: usize) u32 {
const end = @min(cursor_pos, text.len);
return self.measureText(text[0..end]);
}
/// Set the text measurement function (typically from TTF font)
pub fn setTextMeasureFn(self: *Self, measure_fn: ?*const fn ([]const u8) u32) void {
self.text_measure_fn = measure_fn;
}
/// Set character width for fallback measurement (bitmap fonts)
pub fn setCharWidth(self: *Self, width: u32) void {
self.char_width = width;
}
/// Get current time in milliseconds /// Get current time in milliseconds
pub fn getTime(self: Self) u64 { pub fn getTime(self: Self) u64 {
return self.current_time_ms; return self.current_time_ms;

View file

@ -312,15 +312,7 @@ pub const Framebuffer = struct {
const t: u32 = thickness; const t: u32 = thickness;
// For thin outlines, we can use the difference of two rounded rects // Draw rounded rect outline using stroke approach
// Outer rect
self.fillRoundedRect(x, y, w, h, color, radius, aa);
// Inner rect (punch out with background)
// This is a simplification - proper impl would track background color
// For now, we'll draw the outline pixel by pixel
// Actually, let's do this properly with a stroke approach
const max_radius = @min(w, h) / 2; const max_radius = @min(w, h) / 2;
const r: u32 = @min(@as(u32, radius), max_radius); const r: u32 = @min(@as(u32, radius), max_radius);
const inner_r: u32 = if (r > t) r - t else 0; const inner_r: u32 = if (r > t) r - t else 0;

View file

@ -376,11 +376,12 @@ fn drawRow(
.normal => row_bg, .normal => row_bg,
}; };
// Selection overlay - color depende de si la tabla tiene focus // Selection overlay - SOLO la fila seleccionada cambia de color
// El color depende de si la tabla tiene focus
if (is_selected_row) { if (is_selected_row) {
const selection_color = if (has_focus) colors.selected_row else colors.selected_row_unfocus; row_bg = if (has_focus) colors.selected_row else colors.selected_row_unfocus;
row_bg = blendColor(row_bg, selection_color, 0.5);
} }
// Las filas NO seleccionadas mantienen row_bg (row_normal o row_alternate)
// Draw row background // Draw row background
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, config.row_height, row_bg)); ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, config.row_height, row_bg));

View file

@ -36,6 +36,10 @@ pub const AutoCompleteState = struct {
/// Last filter text (for change detection) /// Last filter text (for change detection)
last_filter: [256]u8 = [_]u8{0} ** 256, last_filter: [256]u8 = [_]u8{0} ** 256,
last_filter_len: usize = 0, 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,
const Self = @This(); const Self = @This();
@ -84,6 +88,29 @@ pub const AutoCompleteState = struct {
self.cursor += 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) /// Delete character before cursor (backspace)
pub fn backspace(self: *Self) void { pub fn backspace(self: *Self) void {
if (self.cursor == 0) return; if (self.cursor == 0) return;
@ -255,19 +282,46 @@ pub fn autocompleteRect(
if (bounds.isEmpty()) return result; 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 mouse = ctx.input.mousePos();
const input_hovered = bounds.contains(mouse.x, mouse.y); const input_hovered = bounds.contains(mouse.x, mouse.y);
const input_clicked = input_hovered and ctx.input.mousePressed(.left); const input_clicked = input_hovered and ctx.input.mousePressed(.left);
// Determine if we should be focused (simple focus tracking) // Handle click to request focus
var is_focused = state.open;
if (input_clicked and !config.disabled) { if (input_clicked and !config.disabled) {
is_focused = true; ctx.requestFocus(widget_id);
if (config.show_on_focus) { if (config.show_on_focus) {
state.openDropdown(); 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 // Draw input field background
const border_color = if (is_focused and !config.disabled) const border_color = if (is_focused and !config.disabled)
colors.input_border_focus colors.input_border_focus
@ -280,14 +334,17 @@ pub fn autocompleteRect(
// Get current filter text // Get current filter text
const filter_text = state.text(); const filter_text = state.text();
// Check if text changed // 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]); const text_changed = !std.mem.eql(u8, filter_text, state.last_filter[0..state.last_filter_len]);
if (text_changed) { if (text_changed) {
result.text_changed = true;
// Update last filter // Update last filter
const copy_len = @min(filter_text.len, state.last_filter.len); const copy_len = @min(filter_text.len, state.last_filter.len);
@memcpy(state.last_filter[0..copy_len], filter_text[0..copy_len]); @memcpy(state.last_filter[0..copy_len], filter_text[0..copy_len]);
state.last_filter_len = 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 // Reset selection when text changes
state.highlighted = 0; state.highlighted = 0;
state.scroll_offset = 0; state.scroll_offset = 0;
@ -296,6 +353,7 @@ pub fn autocompleteRect(
state.open = true; state.open = true;
} }
} }
}
// Draw input text or placeholder // Draw input text or placeholder
const inner = bounds.shrink(config.padding); const inner = bounds.shrink(config.padding);
@ -314,7 +372,9 @@ pub fn autocompleteRect(
// Draw cursor if focused // Draw cursor if focused
if (is_focused and !config.disabled) { if (is_focused and !config.disabled) {
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * 8)); // 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));
ctx.pushCommand(Command.rect(cursor_x, text_y, 2, char_height, Style.Color.rgb(200, 200, 200))); ctx.pushCommand(Command.rect(cursor_x, text_y, 2, char_height, Style.Color.rgb(200, 200, 200)));
} }
@ -439,15 +499,15 @@ pub fn autocompleteRect(
.end => { .end => {
state.cursor = state.len; state.cursor = state.len;
}, },
else => { else => {},
// Handle text input
if (event.char) |c| {
if (c >= 32 and c < 127) {
state.insertChar(@intCast(c));
} }
} }
},
} // 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;
} }
} }

View file

@ -371,8 +371,9 @@ pub fn textInputRect(
// Draw cursor if focused // Draw cursor if focused
// Hybrid behavior: blinks while active, solid after idle timeout // Hybrid behavior: blinks while active, solid after idle timeout
if (has_focus and !config.readonly) { if (has_focus and !config.readonly) {
const char_width: u32 = 8; // Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width)); const cursor_offset = ctx.measureTextToCursor(display_text, state.cursor);
const cursor_x = inner.x + @as(i32, @intCast(cursor_offset));
const cursor_color = theme.foreground; const cursor_color = theme.foreground;
// Determine if cursor should be visible // Determine if cursor should be visible
@ -405,11 +406,13 @@ pub fn textInputRect(
// Draw selection if any // Draw selection if any
if (state.selection_start) |sel_start| { if (state.selection_start) |sel_start| {
const char_width: u32 = 8;
const start = @min(sel_start, state.cursor); const start = @min(sel_start, state.cursor);
const end = @max(sel_start, state.cursor); const end = @max(sel_start, state.cursor);
const sel_x = inner.x + @as(i32, @intCast(start * char_width)); // Use ctx.measureTextToCursor for accurate selection with variable-width fonts
const sel_w: u32 = @intCast((end - start) * char_width); const start_offset = ctx.measureTextToCursor(display_text, start);
const end_offset = ctx.measureTextToCursor(display_text, end);
const sel_x = inner.x + @as(i32, @intCast(start_offset));
const sel_w: u32 = end_offset - start_offset;
if (sel_w > 0) { if (sel_w > 0) {
ctx.pushCommand(Command.rect( ctx.pushCommand(Command.rect(