//! 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)); }