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>
404 lines
14 KiB
Zig
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));
|
|
}
|