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>
This commit is contained in:
parent
a377a00803
commit
f077c87dfc
4 changed files with 138 additions and 26 deletions
|
|
@ -39,6 +39,9 @@
|
|||
| 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-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)
|
||||
Widget de tabla avanzada con schema, CRUD, sorting, lookup, multi-select, search, validation.
|
||||
→ 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`
|
||||
|
|
|
|||
|
|
@ -99,12 +99,20 @@ pub const Context = struct {
|
|||
/// Used for idle detection (e.g., cursor stops blinking after inactivity)
|
||||
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,
|
||||
/// cursor becomes solid and no animation frames are needed.
|
||||
pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
/// 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();
|
||||
|
||||
|
|
@ -285,6 +293,38 @@ pub const Context = struct {
|
|||
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
|
||||
pub fn getTime(self: Self) u64 {
|
||||
return self.current_time_ms;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ pub const AutoCompleteState = struct {
|
|||
/// 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,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
|
|
@ -84,6 +88,29 @@ pub const AutoCompleteState = struct {
|
|||
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;
|
||||
|
|
@ -255,19 +282,46 @@ pub fn autocompleteRect(
|
|||
|
||||
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);
|
||||
|
||||
// Determine if we should be focused (simple focus tracking)
|
||||
var is_focused = state.open;
|
||||
// Handle click to request focus
|
||||
if (input_clicked and !config.disabled) {
|
||||
is_focused = true;
|
||||
ctx.requestFocus(widget_id);
|
||||
if (config.show_on_focus) {
|
||||
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
|
||||
|
|
@ -280,20 +334,24 @@ pub fn autocompleteRect(
|
|||
// Get current filter 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]);
|
||||
if (text_changed) {
|
||||
result.text_changed = true;
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +372,9 @@ pub fn autocompleteRect(
|
|||
|
||||
// Draw cursor if focused
|
||||
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)));
|
||||
}
|
||||
|
||||
|
|
@ -439,16 +499,16 @@ pub fn autocompleteRect(
|
|||
.end => {
|
||||
state.cursor = state.len;
|
||||
},
|
||||
else => {
|
||||
// Handle text input
|
||||
if (event.char) |c| {
|
||||
if (c >= 32 and c < 127) {
|
||||
state.insertChar(@intCast(c));
|
||||
}
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw dropdown if open and has items
|
||||
|
|
|
|||
|
|
@ -371,8 +371,9 @@ pub fn textInputRect(
|
|||
// Draw cursor if focused
|
||||
// Hybrid behavior: blinks while active, solid after idle timeout
|
||||
if (has_focus and !config.readonly) {
|
||||
const char_width: u32 = 8;
|
||||
const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width));
|
||||
// Use ctx.measureTextToCursor for accurate cursor positioning with variable-width fonts
|
||||
const cursor_offset = ctx.measureTextToCursor(display_text, state.cursor);
|
||||
const cursor_x = inner.x + @as(i32, @intCast(cursor_offset));
|
||||
const cursor_color = theme.foreground;
|
||||
|
||||
// Determine if cursor should be visible
|
||||
|
|
@ -405,11 +406,13 @@ pub fn textInputRect(
|
|||
|
||||
// Draw selection if any
|
||||
if (state.selection_start) |sel_start| {
|
||||
const char_width: u32 = 8;
|
||||
const start = @min(sel_start, state.cursor);
|
||||
const end = @max(sel_start, state.cursor);
|
||||
const sel_x = inner.x + @as(i32, @intCast(start * char_width));
|
||||
const sel_w: u32 = @intCast((end - start) * char_width);
|
||||
// Use ctx.measureTextToCursor for accurate selection with variable-width fonts
|
||||
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) {
|
||||
ctx.pushCommand(Command.rect(
|
||||
|
|
|
|||
Loading…
Reference in a new issue