zcatgui/src/widgets/textarea/textarea.zig
reugenio e0cbbf6413 feat: Focus ring AA para todos los widgets focusables
Widgets actualizados:
- NumberEntry: esquinas redondeadas + focus ring
- Radio: esquinas redondeadas para círculos + focus ring en opción
- Slider: esquinas redondeadas en track/thumb + focus ring
- Tabs: esquinas redondeadas en tab seleccionado + focus ring
- Table: focus ring alrededor de toda la tabla
- TextArea: esquinas redondeadas + focus ring

Nuevos campos:
- TableColors.focus_ring para consistencia

Total: +135 LOC en 7 archivos

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:24:50 +01:00

404 lines
14 KiB
Zig

//! TextArea Widget - Multi-line text editor
//!
//! A multi-line text input with cursor navigation, selection, and scrolling.
//! Supports line wrapping and handles large documents efficiently.
//!
//! This module re-exports types from the textarea/ subdirectory.
const std = @import("std");
const Context = @import("../../core/context.zig").Context;
const Command = @import("../../core/command.zig");
const Layout = @import("../../core/layout.zig");
const Style = @import("../../core/style.zig");
// Re-export types
pub const types = @import("types.zig");
pub const TextAreaConfig = types.TextAreaConfig;
pub const TextAreaColors = types.TextAreaColors;
pub const TextAreaResult = types.TextAreaResult;
// Re-export state
pub const state = @import("state.zig");
pub const TextAreaState = state.TextAreaState;
// Import render helpers
const render = @import("render.zig");
// =============================================================================
// Public API
// =============================================================================
/// Draw a text area and return interaction result
pub fn textArea(ctx: *Context, textarea_state: *TextAreaState) TextAreaResult {
return textAreaEx(ctx, textarea_state, .{}, .{});
}
/// Draw a text area with custom configuration
pub fn textAreaEx(
ctx: *Context,
textarea_state: *TextAreaState,
config: TextAreaConfig,
colors: TextAreaColors,
) TextAreaResult {
const bounds = ctx.layout.nextRect();
return textAreaRect(ctx, bounds, textarea_state, config, colors);
}
/// Draw a text area in a specific rectangle
pub fn textAreaRect(
ctx: *Context,
bounds: Layout.Rect,
textarea_state: *TextAreaState,
config: TextAreaConfig,
colors: TextAreaColors,
) TextAreaResult {
var result = TextAreaResult{
.changed = false,
.clicked = false,
.cursor_line = 0,
.cursor_col = 0,
};
if (bounds.isEmpty()) return result;
// Generate unique ID for this widget based on buffer memory address
const widget_id: u64 = @intFromPtr(textarea_state.buffer.ptr);
// Register as focusable in the active focus group
ctx.registerFocusable(widget_id);
// Check mouse interaction
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y);
const clicked = hovered and ctx.input.mousePressed(.left);
if (clicked) {
// Request focus through the focus system
ctx.requestFocus(widget_id);
result.clicked = true;
}
// Check if this widget has focus
const has_focus = ctx.hasFocus(widget_id);
textarea_state.focused = has_focus;
// Get colors
const bg_color = if (has_focus) colors.background.lighten(5) else colors.background;
const border_color = if (has_focus) colors.border_focused else colors.border;
// Draw background and border (with rounded corners in fancy mode)
const corner_radius: u8 = 3;
if (Style.isFancy() and corner_radius > 0) {
ctx.pushCommand(Command.roundedRect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, corner_radius));
ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color, corner_radius));
// Focus ring
if (has_focus) {
ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, corner_radius));
}
} else {
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
}
// Calculate dimensions
const char_width: u32 = 8;
const char_height: u32 = 8;
const line_height: u32 = char_height + 2;
// Line numbers width
const line_num_width: u32 = if (config.line_numbers)
@as(u32, @intCast(render.countDigits(textarea_state.lineCount()))) * char_width + 8
else
0;
// Inner area for text
var text_area = bounds.shrink(config.padding);
if (text_area.isEmpty()) return result;
// Draw line numbers gutter
if (config.line_numbers and line_num_width > 0) {
ctx.pushCommand(Command.rect(
text_area.x,
text_area.y,
line_num_width,
text_area.h,
colors.line_numbers_bg,
));
// Adjust text area to exclude gutter
text_area = Layout.Rect.init(
text_area.x + @as(i32, @intCast(line_num_width)),
text_area.y,
text_area.w -| line_num_width,
text_area.h,
);
}
if (text_area.isEmpty()) return result;
// Calculate visible area
const visible_lines = text_area.h / line_height;
const visible_cols = text_area.w / char_width;
// Handle keyboard input if focused
if (textarea_state.focused and !config.readonly) {
const text_in = ctx.input.getTextInput();
if (text_in.len > 0) {
// Check for tab
for (text_in) |c| {
if (c == '\t') {
// Insert spaces for tab
var spaces: [8]u8 = undefined;
const count = @min(config.tab_size, 8);
@memset(spaces[0..count], ' ');
textarea_state.insert(spaces[0..count]);
} else {
textarea_state.insert(&[_]u8{c});
}
}
result.changed = true;
}
}
// Ensure cursor is visible
textarea_state.ensureCursorVisible(visible_lines, visible_cols);
// Get cursor position
const cursor_pos = textarea_state.getCursorPosition();
result.cursor_line = cursor_pos.line;
result.cursor_col = cursor_pos.col;
// Draw text line by line
const txt = textarea_state.text();
var line_num: usize = 0;
var line_start: usize = 0;
for (txt, 0..) |c, i| {
if (c == '\n') {
if (line_num >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) {
const draw_line = line_num - textarea_state.scroll_y;
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
// Draw line number
if (config.line_numbers) {
render.drawLineNumber(
ctx,
bounds.x + @as(i32, @intCast(config.padding)),
y,
line_num + 1,
colors.line_numbers_fg,
);
}
// Draw line text
const line_text = txt[line_start..i];
render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text);
// Draw selection on this line
if (textarea_state.selection_start != null) {
render.drawLineSelection(
ctx,
text_area.x,
y,
line_start,
i,
textarea_state.cursor,
textarea_state.selection_start.?,
textarea_state.scroll_x,
visible_cols,
char_width,
line_height,
colors.selection,
);
}
// Draw cursor if on this line
if (textarea_state.focused and cursor_pos.line == line_num) {
const cursor_x_pos = cursor_pos.col -| textarea_state.scroll_x;
if (cursor_x_pos < visible_cols) {
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
}
}
}
line_num += 1;
line_start = i + 1;
}
}
// Handle last line (no trailing newline)
if (line_start <= txt.len and line_num >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) {
const draw_line = line_num - textarea_state.scroll_y;
const y = text_area.y + @as(i32, @intCast(draw_line * line_height));
// Draw line number
if (config.line_numbers) {
render.drawLineNumber(
ctx,
bounds.x + @as(i32, @intCast(config.padding)),
y,
line_num + 1,
colors.line_numbers_fg,
);
}
// Draw line text
const line_text = if (line_start < txt.len) txt[line_start..] else "";
render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text);
// Draw selection on this line
if (textarea_state.selection_start != null) {
render.drawLineSelection(
ctx,
text_area.x,
y,
line_start,
txt.len,
textarea_state.cursor,
textarea_state.selection_start.?,
textarea_state.scroll_x,
visible_cols,
char_width,
line_height,
colors.selection,
);
}
// Draw cursor if on this line
if (textarea_state.focused and cursor_pos.line == line_num) {
const cursor_x_pos = cursor_pos.col -| textarea_state.scroll_x;
if (cursor_x_pos < visible_cols) {
const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width));
ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor));
}
}
}
// Draw placeholder if empty
if (textarea_state.len == 0 and config.placeholder.len > 0) {
const y = text_area.y;
ctx.pushCommand(Command.text(text_area.x, y, config.placeholder, colors.placeholder));
}
return result;
}
// =============================================================================
// Tests
// =============================================================================
test "TextAreaState insert" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello");
try std.testing.expectEqualStrings("Hello", textarea_state.text());
try std.testing.expectEqual(@as(usize, 5), textarea_state.cursor);
textarea_state.insertNewline();
textarea_state.insert("World");
try std.testing.expectEqualStrings("Hello\nWorld", textarea_state.text());
}
test "TextAreaState line count" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Line 1");
try std.testing.expectEqual(@as(usize, 1), textarea_state.lineCount());
textarea_state.insertNewline();
textarea_state.insert("Line 2");
try std.testing.expectEqual(@as(usize, 2), textarea_state.lineCount());
textarea_state.insertNewline();
textarea_state.insertNewline();
textarea_state.insert("Line 4");
try std.testing.expectEqual(@as(usize, 4), textarea_state.lineCount());
}
test "TextAreaState cursor position" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello\nWorld\nTest");
// Cursor at end
const pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 2), pos.line);
try std.testing.expectEqual(@as(usize, 4), pos.col);
}
test "TextAreaState cursor up/down" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Line 1\nLine 2\nLine 3");
// Move up
textarea_state.cursorUp(false);
var pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 1), pos.line);
textarea_state.cursorUp(false);
pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 0), pos.line);
// Move down
textarea_state.cursorDown(false);
pos = textarea_state.getCursorPosition();
try std.testing.expectEqual(@as(usize, 1), pos.line);
}
test "TextAreaState home/end" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello World");
textarea_state.cursorHome(false);
try std.testing.expectEqual(@as(usize, 0), textarea_state.cursor);
textarea_state.cursorEnd(false);
try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor);
}
test "TextAreaState selection" {
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
textarea_state.insert("Hello World");
textarea_state.selectAll();
try std.testing.expectEqual(@as(?usize, 0), textarea_state.selection_start);
try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor);
textarea_state.insert("X");
try std.testing.expectEqualStrings("X", textarea_state.text());
}
test "textArea generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var buf: [256]u8 = undefined;
var textarea_state = TextAreaState.init(&buf);
ctx.beginFrame();
ctx.layout.row_height = 100;
_ = textArea(&ctx, &textarea_state);
// Should generate: rect (bg) + rect_outline (border)
try std.testing.expect(ctx.commands.items.len >= 2);
ctx.endFrame();
}
test "countDigits" {
try std.testing.expectEqual(@as(usize, 1), render.countDigits(0));
try std.testing.expectEqual(@as(usize, 1), render.countDigits(5));
try std.testing.expectEqual(@as(usize, 2), render.countDigits(10));
try std.testing.expectEqual(@as(usize, 3), render.countDigits(100));
try std.testing.expectEqual(@as(usize, 4), render.countDigits(1234));
}