diff --git a/src/components/text_area.zig b/src/components/text_area.zig index 18e78da..b08019b 100644 --- a/src/components/text_area.zig +++ b/src/components/text_area.zig @@ -5,7 +5,6 @@ const std = @import("std"); const keys = @import("../input/keys.zig"); const style_mod = @import("../style/style.zig"); const Color = @import("../style/color.zig").Color; -const measure = @import("../layout/measure.zig"); const unicode = @import("../unicode.zig"); pub const TextArea = struct { @@ -582,16 +581,7 @@ pub const TextArea = struct { // Show placeholder on first empty line if (is_empty and r.line_idx == 0 and r.is_first_segment and self.placeholder.len > 0) { - const styled = try self.placeholder_style.render(allocator, self.placeholder); - defer allocator.free(styled); - try writer.writeAll(styled); - const placeholder_width = measure.width(self.placeholder); - const max_width: usize = text_width; - var rendered_width = @min(placeholder_width, max_width); - while (rendered_width < max_width) { - try writer.writeByte(' '); - rendered_width += 1; - } + try self.renderPlaceholder(writer, allocator, text_width); continue; } @@ -637,20 +627,56 @@ pub const TextArea = struct { // Show placeholder on first empty line if (is_empty and line_idx == 0 and self.placeholder.len > 0) { - const styled = try self.placeholder_style.render(allocator, self.placeholder); - defer allocator.free(styled); - try writer.writeAll(styled); + try self.renderPlaceholder(writer, allocator, text_width); continue; } // Render line content with cursor try self.renderLine(writer, allocator, line.items, line_idx, text_width); + } else { + // Pad empty rows to full width + var i: usize = 0; + while (i < text_width) : (i += 1) { + try writer.writeByte(' '); + } } } return result.toOwnedSlice(); } + fn renderPlaceholder(self: *const TextArea, writer: anytype, allocator: std.mem.Allocator, max_width: u16) !void { + const width_limit: usize = max_width; + if (width_limit == 0) return; + + var rendered_width: usize = 0; + var byte_idx: usize = 0; + while (byte_idx < self.placeholder.len and rendered_width < width_limit) { + const byte_len = std.unicode.utf8ByteSequenceLength(self.placeholder[byte_idx]) catch 1; + if (byte_idx + byte_len > self.placeholder.len) break; + const char_slice = self.placeholder[byte_idx..][0..byte_len]; + + const cp = std.unicode.utf8Decode(char_slice) catch { + byte_idx += 1; + rendered_width += 1; + continue; + }; + const cw = wrapDisplayWidth(unicode.charWidth(cp), width_limit); + if (rendered_width + cw > width_limit) break; + + const styled = try self.placeholder_style.render(allocator, char_slice); + defer allocator.free(styled); + try writer.writeAll(styled); + byte_idx += byte_len; + rendered_width += cw; + } + + while (rendered_width < width_limit) { + try writer.writeByte(' '); + rendered_width += 1; + } + } + fn renderWrappedLineSegment( self: *const TextArea, writer: anytype, diff --git a/tests/input_tests.zig b/tests/input_tests.zig index 618bbaf..f94ce6c 100644 --- a/tests/input_tests.zig +++ b/tests/input_tests.zig @@ -258,3 +258,254 @@ test "TextArea word_wrap keeps cursor visible by wrapped row" { defer testing.allocator.free(plain); try testing.expectEqualStrings("fghij\nk ", plain); } + +// ============================================================================ +// Tests for TextArea setSize/view fix - placeholder and empty row padding +// ============================================================================ + +test "TextArea setSize without setValue pads placeholder to full width" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + // Initialize with placeholder + area.placeholder = "Type here"; + + // Set size WITHOUT calling setValue (the bug scenario) + area.setSize(50, 3); + + // Render view + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + // Verify full 50x3 viewport shape + var lines = std.mem.splitScalar(u8, plain, '\n'); + const line0 = lines.next().?; + const line1 = lines.next().?; + const line2 = lines.next().?; + try testing.expectEqual(@as(usize, 50), line0.len); + try testing.expect(std.mem.startsWith(u8, line0, "Type here")); + try testing.expectEqual(@as(usize, 50), line1.len); + try testing.expectEqual(@as(usize, 50), line2.len); + for (line1) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + for (line2) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + try testing.expect(lines.next() == null); +} + +test "TextArea placeholder wider than width is clipped and padded" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + area.placeholder = "This is a very long placeholder"; + area.setSize(10, 2); + + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + var lines = std.mem.splitScalar(u8, plain, '\n'); + const line0 = lines.next().?; + const line1 = lines.next().?; + try testing.expectEqual(@as(usize, 10), line0.len); + try testing.expectEqual(@as(usize, 10), line1.len); + for (line1) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + try testing.expect(lines.next() == null); +} + +test "TextArea wrapped placeholder wider than width is clipped and padded" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + area.word_wrap = true; + area.placeholder = "This is a very long placeholder"; + area.setSize(10, 2); + + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + var lines = std.mem.splitScalar(u8, plain, '\n'); + const line0 = lines.next().?; + const line1 = lines.next().?; + try testing.expectEqual(@as(usize, 10), line0.len); + try testing.expectEqual(@as(usize, 10), line1.len); + for (line1) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + try testing.expect(lines.next() == null); +} + +test "TextArea pads empty rows to full width" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + // Set content to just one line + try area.setValue("Single line"); + area.setSize(30, 5); + + // Render view + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + // Split into lines + var lines = std.mem.splitScalar(u8, plain, '\n'); + + // First line should have content padded to 30 chars + const line0 = lines.next().?; + try testing.expectEqual(@as(usize, 30), line0.len); + try testing.expect(std.mem.startsWith(u8, line0, "Single line")); + + // Remaining lines should be padded to 30 chars of spaces + for (0..4) |_| { + const line = lines.next().?; + try testing.expectEqual(@as(usize, 30), line.len); + for (line) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + } + try testing.expect(lines.next() == null); +} + +test "TextArea renders at correct width after setSize without setValue" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + // Initialize with placeholder (common usage pattern) + area.placeholder = "Enter text..."; + area.placeholder_style = blk: { + var s = zz.style.Style{}; + s = s.fg(zz.Color.gray(12)); + s = s.inline_style(true); + break :blk s; + }; + + // Simulate resize to 80x24 terminal + area.setSize(80, 24); + + // Render without calling setValue + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + + // Strip ANSI and verify dimensions + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + var lines = std.mem.splitScalar(u8, plain, '\n'); + for (0..24) |_| { + const line = lines.next().?; + try testing.expectEqual(@as(usize, 80), line.len); + } + try testing.expect(lines.next() == null); +} + +test "TextArea multiple resize operations maintain correct width" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + area.placeholder = "Test"; + + // First resize to small + area.setSize(20, 5); + const rendered1 = try area.view(testing.allocator); + defer testing.allocator.free(rendered1); + const plain1 = try stripAnsi(testing.allocator, rendered1); + defer testing.allocator.free(plain1); + + var lines1 = std.mem.splitScalar(u8, plain1, '\n'); + const first_line1 = lines1.next().?; + try testing.expectEqual(@as(usize, 20), first_line1.len); + + // Then resize to large + area.setSize(100, 10); + const rendered2 = try area.view(testing.allocator); + defer testing.allocator.free(rendered2); + const plain2 = try stripAnsi(testing.allocator, rendered2); + defer testing.allocator.free(plain2); + + var lines2 = std.mem.splitScalar(u8, plain2, '\n'); + const first_line2 = lines2.next().?; + try testing.expectEqual(@as(usize, 100), first_line2.len); + + // Then resize back to medium + area.setSize(50, 3); + const rendered3 = try area.view(testing.allocator); + defer testing.allocator.free(rendered3); + const plain3 = try stripAnsi(testing.allocator, rendered3); + defer testing.allocator.free(plain3); + + var lines3 = std.mem.splitScalar(u8, plain3, '\n'); + const first_line3 = lines3.next().?; + try testing.expectEqual(@as(usize, 50), first_line3.len); +} + +test "TextArea empty content renders all rows at correct width" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + // Don't set any content or placeholder + area.setSize(40, 10); + + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + // All 10 rows should be 40 spaces + var lines = std.mem.splitScalar(u8, plain, '\n'); + var line_count: usize = 0; + while (lines.next()) |line| : (line_count += 1) { + if (line_count >= 10) break; + try testing.expectEqual(@as(usize, 40), line.len); + // Verify all spaces + for (line) |c| { + try testing.expectEqual(@as(u8, ' '), c); + } + } + try testing.expectEqual(@as(usize, 10), line_count); +} + +test "TextArea partial content fills remaining rows" { + var area = zz.TextArea.init(testing.allocator); + defer area.deinit(); + + // Set 3 lines of content + try area.setValue("Line 1\nLine 2\nLine 3"); + area.setSize(25, 10); + + const rendered = try area.view(testing.allocator); + defer testing.allocator.free(rendered); + const plain = try stripAnsi(testing.allocator, rendered); + defer testing.allocator.free(plain); + + var lines = std.mem.splitScalar(u8, plain, '\n'); + + // First 3 lines have content + try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 1")); + try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 2")); + try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 3")); + + // Next 7 lines should be empty (spaces) + var empty_count: usize = 0; + while (lines.next()) |line| { + if (empty_count >= 7) break; + try testing.expectEqual(@as(usize, 25), line.len); + var all_spaces = true; + for (line) |c| { + if (c != ' ') all_spaces = false; + } + try testing.expect(all_spaces); + empty_count += 1; + } + try testing.expectEqual(@as(usize, 7), empty_count); +}