Compare commits
3 commits
7d91835fb7
...
f077c87dfc
| Author | SHA1 | Date | |
|---|---|---|---|
| f077c87dfc | |||
| a377a00803 | |||
| 3d44631cc3 |
6 changed files with 143 additions and 38 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,20 +334,24 @@ 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;
|
||||||
// Reset selection when text changes
|
|
||||||
state.highlighted = 0;
|
// Only trigger changes after first frame (first frame is just sync)
|
||||||
state.scroll_offset = 0;
|
if (!is_first_frame) {
|
||||||
// Open dropdown when typing
|
result.text_changed = true;
|
||||||
if (filter_text.len >= config.min_chars) {
|
// Reset selection when text changes
|
||||||
state.open = true;
|
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
|
// 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,16 +499,16 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw dropdown if open and has items
|
// Draw dropdown if open and has items
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue