Compare commits

..

No commits in common. "f077c87dfcc814f0716c1f32a07f005f3d136c89" and "7d91835fb7deffa89b05043161b5dbbc53e2509d" have entirely different histories.

6 changed files with 38 additions and 143 deletions

View file

@ -39,9 +39,6 @@
| 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) |
--- ---
@ -58,9 +55,3 @@ 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,20 +99,12 @@ 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.
/// 300ms = ~3.3 blinks/sec (faster for better editing feedback) pub const CURSOR_BLINK_PERIOD_MS: u64 = 500;
pub const CURSOR_BLINK_PERIOD_MS: u64 = 300;
const Self = @This(); const Self = @This();
@ -293,38 +285,6 @@ 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,7 +312,15 @@ pub const Framebuffer = struct {
const t: u32 = thickness; const t: u32 = thickness;
// Draw rounded rect outline using stroke approach // For thin outlines, we can use the difference of two rounded rects
// 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,12 +376,11 @@ fn drawRow(
.normal => row_bg, .normal => row_bg,
}; };
// Selection overlay - SOLO la fila seleccionada cambia de color // Selection overlay - color depende de si la tabla tiene focus
// El color depende de si la tabla tiene focus
if (is_selected_row) { if (is_selected_row) {
row_bg = if (has_focus) colors.selected_row else colors.selected_row_unfocus; const selection_color = 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,10 +36,6 @@ 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();
@ -88,29 +84,6 @@ 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;
@ -282,46 +255,19 @@ 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);
// Handle click to request focus // Determine if we should be focused (simple focus tracking)
var is_focused = state.open;
if (input_clicked and !config.disabled) { if (input_clicked and !config.disabled) {
ctx.requestFocus(widget_id); is_focused = true;
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
@ -334,17 +280,14 @@ 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 (but not on first frame - that's just initialization) // Check if text changed
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;
@ -353,7 +296,6 @@ 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);
@ -372,9 +314,7 @@ pub fn autocompleteRect(
// Draw cursor if focused // Draw cursor if focused
if (is_focused and !config.disabled) { if (is_focused and !config.disabled) {
// Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts const cursor_x = inner.x + @as(i32, @intCast(state.cursor * 8));
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)));
} }
@ -499,15 +439,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,9 +371,8 @@ 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) {
// Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts const char_width: u32 = 8;
const cursor_offset = ctx.measureTextToCursor(display_text, state.cursor); const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
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
@ -406,13 +405,11 @@ 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);
// Use ctx.measureTextToCursor for accurate selection with variable-width fonts const sel_x = inner.x + @as(i32, @intCast(start * char_width));
const start_offset = ctx.measureTextToCursor(display_text, start); const sel_w: u32 = @intCast((end - start) * char_width);
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(